From 0b5175fa5782e52baa0e23113d24ecaf1a3d5d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 04:23:41 +0700 Subject: [PATCH 01/21] test(v21): establish fresh Learned Policy V3 causal RED --- ...learning-policy-p1-decision-record.test.js | 94 +++++++++++++++++++ ...learning-policy-p1-outcome-binding.test.js | 62 ++++++++++++ ...g-policy-p1-production-consumption.test.js | 24 +++++ .../v21-learning-policy-p1-projection.test.js | 72 ++++++++++++++ ...21-learning-policy-p1-supply-chain.test.js | 25 +++++ .../v21-learning-policy-p1-vw-runtime.test.js | 40 ++++++++ 6 files changed, 317 insertions(+) create mode 100644 tests/wp0/v21-learning-policy-p1-decision-record.test.js create mode 100644 tests/wp0/v21-learning-policy-p1-outcome-binding.test.js create mode 100644 tests/wp0/v21-learning-policy-p1-production-consumption.test.js create mode 100644 tests/wp0/v21-learning-policy-p1-projection.test.js create mode 100644 tests/wp0/v21-learning-policy-p1-supply-chain.test.js create mode 100644 tests/wp0/v21-learning-policy-p1-vw-runtime.test.js 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); +}); From 125eb50cf2cc391c393b446c21e18234a21e3fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 06:55:34 +0700 Subject: [PATCH 02/21] feat(v21): root-fix Learned Policy production consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the verified V2 Decision→Outcome / VW production blobs, bind production-default policy resolution to canonical native OpenFeature/flagd state, verify content-addressed promoted artifacts, and invoke only the shipped sealed Learning/VW runtime before Model Brain frontier generation. Yance-Failure-First-Red-Head: 0b5175fa5782e52baa0e23113d24ecaf1a3d5d74 Yance-Failure-First-Red-Run: 31842310018 Yance-Failure-First-Red-Conclusion: failure --- THIRD_PARTY_NOTICES.md | 9 + backend/services/contextAwareReplyBrain.js | 99 +++++- .../services/learningDeepTrainingContract.js | 111 ++++++- .../learningOutcomeAttributionService.js | 266 ++++++++++++++++ .../learningPolicyDecisionContract.js | 201 ++++++++++++ .../services/learningPolicyRuntimeAdapter.js | 236 ++++++++++++++ backend/services/learningPromotionAdapter.js | 190 +++++++++-- .../services/replyFeedbackLearningService.js | 7 +- backend/services/storeManagerService.js | 8 +- .../v21-learning-growth-brain-p0.json | 1 + .../python/learning_entrypoint.py | 252 ++++++++++++++- runtime/learning-growth/python/pyproject.toml | 5 +- runtime/learning-growth/python/uv.lock | 15 + .../licenses/vowpal-wabbit-BSD-3-Clause.txt | 46 +++ .../v21LearningPolicyClosedLoopEvidence.js | 296 ++++++++++++++++++ 15 files changed, 1693 insertions(+), 49 deletions(-) create mode 100644 backend/services/learningOutcomeAttributionService.js create mode 100644 backend/services/learningPolicyDecisionContract.js create mode 100644 backend/services/learningPolicyRuntimeAdapter.js create mode 100644 third_party/licenses/vowpal-wabbit-BSD-3-Clause.txt create mode 100644 tools/uat/v21LearningPolicyClosedLoopEvidence.js diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b4740d9e2..f03fb8866 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -295,3 +295,12 @@ Exact Voice Brain source, model and build-tool pins are recorded in `config/upst - License copy: `third_party/licenses/agent-lightning-MIT.txt` - Upstream dependency lock: exact `uv.lock` Git blob `5a98a2ac121b050b0a82f6ac8dc207577ce3af4e` - Yance integration: source-module CORE + APO only, downstream of Learning and Model Brain authority, returning `CANDIDATE_ONLY`. +## Vowpal Wabbit + +- Project: Vowpal Wabbit +- Upstream: `VowpalWabbit/vowpal_wabbit` +- Version: `9.11.2` +- Frozen commit: `122bae254a5b8bc2b774d13b33d53e6dbc2cfba7` +- License: `BSD-3-Clause` +- License copy: `third_party/licenses/vowpal-wabbit-BSD-3-Clause.txt` +- Yance integration: sealed Learning runtime contextual-bandit ADF policy head only. P1 is deterministic (`actionProbability=1.0`, `exploration=false`); Model Brain/LiteLLM remains the final reply-generation authority. diff --git a/backend/services/contextAwareReplyBrain.js b/backend/services/contextAwareReplyBrain.js index ee443d2f7..d244142fc 100644 --- a/backend/services/contextAwareReplyBrain.js +++ b/backend/services/contextAwareReplyBrain.js @@ -22,6 +22,8 @@ const aiDirectorStrategyAuthority = require('./aiDirectorStrategyAuthority').sin const aiWorkbenchDirectorRuleAuthority = require('./aiWorkbenchDirectorRuleAuthority'); const goalDrivenMemoryRecall = require('./goalDrivenMemoryRecallService'); const { singleton: platformCoreRepository } = require('../repositories/platformCoreRepository'); +const { createLearningPolicyRuntimeAdapter } = require('./learningPolicyRuntimeAdapter'); +const { createLearningPolicyDecisionContract } = require('./learningPolicyDecisionContract'); function clean(value) { return String(value == null ? '' : value).trim(); @@ -98,6 +100,9 @@ function memoryCandidatesForRecall(packet = {}) { function branchNameForVariant(value = '') { const variant = clean(value).toLowerCase(); + const exactAction = ['natural_hook', 'playful_attraction', 'direct_advance', 'screen_and_advance', 'leave_aftertaste'] + .find(action => action === variant); + if (exactAction) return exactAction; if (/情趣|暧昧|俏皮|妩媚|女人味|feminine|flirt/u.test(variant)) return 'playful_attraction'; if (/边界|筛选|screen/u.test(variant)) return 'screen_and_advance'; if (/直接|强势|direct/u.test(variant)) return 'direct_advance'; @@ -106,6 +111,17 @@ function branchNameForVariant(value = '') { return 'natural_hook'; } +function learnedPolicyInteractionBand(context = {}) { + const interaction = context.interaction && typeof context.interaction === 'object' ? context.interaction : {}; + const explicit = clean(interaction.engagementBand || interaction.responseBand || interaction.band).toLowerCase(); + if (['low', 'balanced', 'high'].includes(explicit)) return explicit; + const score = Number(interaction.engagementScore ?? interaction.responseRate ?? interaction.reciprocity); + if (!Number.isFinite(score)) return 'balanced'; + if (score < 0.35) return 'low'; + if (score > 0.7) return 'high'; + return 'balanced'; +} + function candidateBranchPlanForCount(value = 3) { const count = Math.max(1, Math.min(5, Number(value || 3))); return ['natural_hook', 'playful_attraction', 'direct_advance', 'screen_and_advance', 'leave_aftertaste'].slice(0, count); @@ -674,10 +690,19 @@ async function contextStillCurrent(previous, current) { return clean(previous.contactId) === clean(current.contactId); } -function createContextAwareReplyBrain({ storeManager, aiGateway, personaBrain, resolveContactId }) { +function createContextAwareReplyBrain({ + storeManager, + aiGateway, + personaBrain, + resolveContactId, + learningPolicyRuntimeAdapter, + learningPolicyDecisionContract +}) { if (!storeManager?.select || !storeManager?.dispatch) throw new TypeError('storeManager is required'); if (!aiGateway?.execute) throw new TypeError('aiGateway is required'); const persona = personaBrain || personaBrainModule.createPersonaBrain(); + const learnedPolicyRuntime = learningPolicyRuntimeAdapter || createLearningPolicyRuntimeAdapter(); + const learnedPolicyDecisions = learningPolicyDecisionContract || createLearningPolicyDecisionContract(); function currentConversationRevision(conversationId) { const selected = storeManager.select(state => Number(state.conversations?.byId?.[conversationId]?.version || 0)); @@ -1081,7 +1106,75 @@ function createContextAwareReplyBrain({ storeManager, aiGateway, personaBrain, r targetLanguage: languageAuthority.code, learningWeights: {}, }); - const branchApplication = applyCandidateBranch(effectiveDirector, candidatePlan.plan, variant); + const policyFeatureBundle = Object.freeze({ + interactionBand: learnedPolicyInteractionBand(socialContext), + performanceMode, + questionPolicy: Number(automaticDirectorPlan.maxQuestions) === 0 ? 'none' : 'optional', + relationshipStage: clean(socialContext.relationshipPotential?.relationshipStage || 'unknown') || 'unknown', + targetLanguage: clean(languageAuthority.code || 'unknown') || 'unknown' + }); + const allowedPolicyActions = (candidatePlan.plan.branches || []) + .map(row => clean(row.strategy)) + .filter(Boolean); + const requestedBaselineAction = branchNameForVariant(variant); + const baselinePolicyAction = allowedPolicyActions.includes(requestedBaselineAction) + ? requestedBaselineAction + : allowedPolicyActions[0]; + const learnedPolicySelection = await learnedPolicyRuntime.selectLearnedPolicyAction({ + featureBundle: policyFeatureBundle, + allowedActions: allowedPolicyActions, + baselineAction: baselinePolicyAction + }); + const branchApplication = applyCandidateBranch( + effectiveDirector, + candidatePlan.plan, + learnedPolicySelection.candidateStrategyBranch + ); + let decisionRecord = null; + let learningPolicyEvidenceEligible = true; + let learningPolicyDegradation = learnedPolicySelection.degradation || null; + try { + decisionRecord = learnedPolicyDecisions.createDecisionRecord({ + contactId, + conversationId, + personaProfileId: personaCtx.profileId || 'owner', + featureBundle: policyFeatureBundle, + allowedActionSet: allowedPolicyActions, + candidateStrategyBranch: learnedPolicySelection.candidateStrategyBranch, + policyVersion: learnedPolicySelection.policyVersion, + behaviorPolicyVersion: learnedPolicySelection.policyVersion, + policyArtifactId: learnedPolicySelection.policyArtifactId, + generation: { + candidatePlanId: candidatePlan.plan.planId, + directorStrategyId: directorStrategy.strategy.strategyId, + contextVersion: socialContext.contextVersion, + conversationRevision + }, + contributingPolicyVersions: { + relationship: `relationship-v${Number(socialContext.entityVersions?.relationship || 0)}`, + memory: `memory-v${Number(socialContext.entityVersions?.memory || 0)}`, + strategy: `director-strategy-v${Number(directorStrategy.strategy.strategyVersion || 1)}`, + candidateRanker: 'candidate-strategy-branch-v1', + routing: 'model-brain-routing-current-v1', + promptProgram: 'context-aware-reply-current-v1' + } + }); + } catch (error) { + learningPolicyEvidenceEligible = false; + learningPolicyDegradation = Object.freeze({ + reasonCode: clean(error.reasonCode || error.code) || 'LEARNING_POLICY_DECISION_EVIDENCE_BLOCKED' + }); + } + const learningPolicyReceipt = Object.freeze({ + authority: 'LearningPolicyRuntimeAdapter', + policyVersion: clean(learnedPolicySelection.policyVersion), + policyArtifactId: clean(learnedPolicySelection.policyArtifactId), + candidateStrategyBranch: clean(learnedPolicySelection.candidateStrategyBranch), + actionProbability: 1, + exploration: false, + evidenceEligible: learningPolicyEvidenceEligible, + degradation: learningPolicyDegradation + }); effectiveDirector = { ...branchApplication.director, strategyId: directorStrategy.strategy.strategyId, @@ -1265,6 +1358,8 @@ function createContextAwareReplyBrain({ storeManager, aiGateway, personaBrain, r emergencyMode, learningEligible, highCapabilityPath, + decisionRecord, + learningPolicy: learningPolicyReceipt, directorRuleStackReceipt: directorRuleStack.receipt, director: { plan: automaticDirectorPlan, diff --git a/backend/services/learningDeepTrainingContract.js b/backend/services/learningDeepTrainingContract.js index 9df1f8f73..518c3d8cb 100644 --- a/backend/services/learningDeepTrainingContract.js +++ b/backend/services/learningDeepTrainingContract.js @@ -27,7 +27,7 @@ function createLearningDeepTrainingContract(options = {}) { } function isLearningEligible(signal = {}) { - return signal.learning_eligible === true || signal.learning_eligible === 1; + return signal.learning_eligible === true || signal.learning_eligible === 1 || signal.learningEligible === true; } function isDoNotLearn(signal = {}) { @@ -35,7 +35,7 @@ function createLearningDeepTrainingContract(options = {}) { } function hasRawPrivatePersistence(signal = {}) { - return signal.signal?.metadata?.rawPrivateChatPersisted === true; + return signal.signal?.metadata?.rawPrivateChatPersisted === true || signal.signal?.rawPrivateChatPersisted === true; } function hasValidScoreSubject(score = {}) { @@ -195,6 +195,110 @@ function createLearningDeepTrainingContract(options = {}) { }); } + function exactOutcomeIds(rows = []) { + return [...new Set(rows.flatMap(row => Array.isArray(row.signal?.outcomes) ? row.signal.outcomes : []) + .map(outcome => clean(outcome?.outcomeId)).filter(Boolean))].sort(); + } + + function hasReplayableDecision(decision = {}) { + const probability = Number(decision.actionProbability); + return Boolean( + clean(decision.decisionId) && clean(decision.candidateStrategyBranch) && + decision.featureBundle && typeof decision.featureBundle === 'object' && !Array.isArray(decision.featureBundle) && + clean(decision.actionId) && clean(decision.actionSetRef) && clean(decision.actionEncodingVersion) && + clean(decision.behaviorPolicyVersion || decision.policyVersion) && + Number.isFinite(probability) && probability > 0 && probability <= 1 && decision.exploration !== true + ); + } + + async function listPolicyOutcomes(decisionIds, scopeType, scopeId) { + if (typeof repository.listPolicyOutcomeSignals === 'function') { + const listed = await repository.listPolicyOutcomeSignals({ decisionIds }); + return Array.isArray(listed) ? listed : []; + } + const listed = await repository.listLearningSignals({ scopeType, scopeId, learningLevel: 'L1', learningEligible: false }); + return (Array.isArray(listed) ? listed : []).filter(row => + clean(row.signal_type || row.signalType) === 'policy_outcome_observed' && decisionIds.includes(clean(row.signal?.decisionId)) + ); + } + + async function projectPolicy(input = {}) { + requireProjectionDependencies(); + const scopeType = clean(input.scopeType); + const scopeId = clean(input.scopeId); + const listed = await repository.listLearningSignals({ scopeType, scopeId, learningLevel: 'L1', learningEligible: true }); + const sources = (Array.isArray(listed) ? listed : []).filter(signal => + isLearningEligible(signal) && !isDoNotLearn(signal) && !hasRawPrivatePersistence(signal) + && clean(signal.signal_type || signal.signalType) === 'candidate_sent' + && clean(signal.signal?.decisionRecord?.decisionId) + ); + assertCanonicalScope(sources, scopeType, scopeId, 'LEARNING_POLICY_SOURCE_SCOPE_MISMATCH'); + const decisionIds = sources.map(row => clean(row.signal.decisionRecord.decisionId)); + const rawOutcomes = await listPolicyOutcomes(decisionIds, scopeType, scopeId); + const trajectory = []; + + for (const source of sources) { + const sourceSignalId = clean(source.signal_id || source.signalId); + const decision = source.signal.decisionRecord; + const decisionId = clean(decision.decisionId); + const joined = rawOutcomes.filter(row => { + const raw = row.signal || {}; + const rawFalseEligible = row.learning_eligible === false || row.learning_eligible === 0 || row.learningEligible === false; + if (!rawFalseEligible || clean(row.signal_type || row.signalType) !== 'policy_outcome_observed') return false; + if (clean(raw.decisionId) !== decisionId) return false; + if (clean(raw.sourceSignalId) && clean(raw.sourceSignalId) !== sourceSignalId) return false; + if (clean(raw.personId) && clean(decision.personId) && clean(raw.personId) !== clean(decision.personId)) return false; + if (clean(raw.conversationId) && clean(decision.conversationId) && clean(raw.conversationId) !== clean(decision.conversationId)) return false; + return true; + }); + const outcomes = Object.freeze(joined.flatMap(row => Array.isArray(row.signal?.outcomes) ? row.signal.outcomes.map(value => Object.freeze({ ...value })) : [])); + const outcomeIds = exactOutcomeIds(joined); + const score = approvedScoreFor(sourceSignalId, input.approvedScoresBySignalId); + const scoreSourceId = clean(score.sourceSignalId || score.eligibleSourceSignalId); + const scoreOutcomeIds = [...new Set((Array.isArray(score.outcomeIds) ? score.outcomeIds : []).map(clean).filter(Boolean))].sort(); + const exactOutcomeSet = outcomeIds.length === scoreOutcomeIds.length && outcomeIds.every((id, index) => id === scoreOutcomeIds[index]); + if ( + scoreSourceId !== sourceSignalId || clean(score.decisionId) !== decisionId || !exactOutcomeSet || + !clean(score.outcomeEvidenceSetRef) || !clean(score.rewardPolicyVersion) + ) { + throw contractError('LEARNING_POLICY_SCORE_EVIDENCE_BINDING_REQUIRED', `Score for ${sourceSignalId} must bind exact source/decision/outcome evidence.`); + } + const minimized = await dataPolicy.minimize({ + text: clean(input.contentBySignalId?.[sourceSignalId]), + signalId: sourceSignalId, + scopeType, + scopeId, + learningEligible: true, + featureBundle: decision.featureBundle + }); + if (!minimized || minimized.allowed !== true) continue; + trajectory.push(Object.freeze({ + sourceSignalId, + signalId: sourceSignalId, + decisionId, + decision: Object.freeze({ ...decision }), + featureBundle: Object.freeze({ ...(decision.featureBundle || {}) }), + outcomes, + approvedScore: score, + score, + minimizedContent: String(minimized.text ?? minimized.minimizedText ?? ''), + vwTrainingEligible: hasReplayableDecision(decision) + })); + } + + const projection = Object.freeze({ + authority: 'Learning', + readOnly: true, + scopeType, + scopeId, + learningLevel: 'L1', + policyProjection: true, + trajectory: Object.freeze(trajectory) + }); + issuedProjections.add(projection); + return projection; + } + async function bindExperimentEvidence(input = {}) { if (!evidenceAdapter || typeof evidenceAdapter.bindTrainingEvidence !== 'function') { throw contractError('LEARNING_DEEP_TRAINING_LANGFUSE_EVIDENCE_REQUIRED', 'Langfuse Dataset/Score evidence adapter is required.'); @@ -231,10 +335,11 @@ function createLearningDeepTrainingContract(options = {}) { return Object.freeze({ projectRelationship, projectGlobal, + projectPolicy, bindExperimentEvidence, rollbackPromotion, authority: 'Learning read-only Deep Training projection' }); } -module.exports = { createLearningDeepTrainingContract }; +module.exports = { createLearningDeepTrainingContract }; \ No newline at end of file diff --git a/backend/services/learningOutcomeAttributionService.js b/backend/services/learningOutcomeAttributionService.js new file mode 100644 index 000000000..8fb9099b7 --- /dev/null +++ b/backend/services/learningOutcomeAttributionService.js @@ -0,0 +1,266 @@ +'use strict'; + +const { canonicalHash } = require('./canonicalSerialization'); +const eventBus = require('./eventBus'); +const personContextAuthority = require('./personContextAuthority').singleton; +const { singleton: platformCoreRepository } = require('../repositories/platformCoreRepository'); + +const AUTHORITY = 'LearningOutcomeAttribution'; +const RAW_SIGNAL_TYPE = 'policy_outcome_observed'; + +function clean(value) { return String(value == null ? '' : value).trim(); } +function attributionError(reasonCode, message, details = {}) { + return Object.assign(new Error(message || reasonCode), { reasonCode, code: reasonCode, ...details }); +} +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} +function timestamp(value) { + const ms = Date.parse(clean(value)); + return Number.isFinite(ms) ? new Date(ms).toISOString() : ''; +} +function unique(values = []) { return [...new Set((Array.isArray(values) ? values : []).map(clean).filter(Boolean))]; } + +function normalizeOutcomes(values = []) { + if (!Array.isArray(values)) throw attributionError('LEARNING_POLICY_OUTCOMES_INVALID', 'outcomes must be an array.'); + const seen = new Set(); + const rows = values.map((row, index) => { + if (!row || typeof row !== 'object' || Array.isArray(row)) { + throw attributionError('LEARNING_POLICY_OUTCOME_INVALID', `Outcome ${index} must be an object.`); + } + const outcomeId = clean(row.outcomeId); + const type = clean(row.type); + const evidenceRef = clean(row.evidenceRef); + if (!outcomeId || !type || !evidenceRef || seen.has(outcomeId)) { + throw attributionError('LEARNING_POLICY_OUTCOME_PROVENANCE_REQUIRED', 'Each outcome requires a unique outcomeId, type and evidenceRef.'); + } + seen.add(outcomeId); + const value = row.value; + if (typeof value !== 'boolean' && !(typeof value === 'number' && Number.isFinite(value)) && value !== null) { + throw attributionError('LEARNING_POLICY_OUTCOME_VALUE_INVALID', 'Outcome values must be finite numbers, booleans or null.'); + } + return deepFreeze({ outcomeId, type, value, evidenceRef }); + }); + return deepFreeze(rows); +} + +function createLearningOutcomeAttributionService(options = {}) { + const repository = options.repository || platformCoreRepository; + const identityAuthority = options.personContextAuthority || personContextAuthority; + const bus = options.eventBus || eventBus; + let started = false; + let listener = null; + + function createOutcomeVector(input = {}) { + const decisionId = clean(input.decisionId); + if (!decisionId) throw attributionError('LEARNING_POLICY_DECISION_ID_REQUIRED', 'OutcomeVector requires decisionId.'); + const observedAt = timestamp(input.observedAt) || clean(input.observedAt); + if (!observedAt) throw attributionError('LEARNING_POLICY_OUTCOME_OBSERVED_AT_REQUIRED', 'OutcomeVector requires observedAt.'); + const outcomes = normalizeOutcomes(input.outcomes || []); + const sourceSignalId = clean(input.sourceSignalId); + const contactId = clean(input.contactId); + const conversationId = clean(input.conversationId); + const personId = clean(input.personId); + const observationWindow = input.observationWindow && typeof input.observationWindow === 'object' + ? deepFreeze({ start: timestamp(input.observationWindow.start) || clean(input.observationWindow.start), end: timestamp(input.observationWindow.end) || clean(input.observationWindow.end) }) + : undefined; + const signals = input.signals && typeof input.signals === 'object' && !Array.isArray(input.signals) + ? deepFreeze(JSON.parse(JSON.stringify(input.signals))) + : undefined; + const outcomeId = clean(input.outcomeId) || `outcome:${canonicalHash({ decisionId, sourceSignalId, observedAt, outcomes, signals: signals || null })}`; + const vector = { + schemaVersion: 1, + authority: AUTHORITY, + signalType: RAW_SIGNAL_TYPE, + outcomeId, + decisionId, + sourceSignalId, + scopeType: conversationId ? 'conversation' : clean(input.scopeType) || 'conversation', + scopeId: conversationId || clean(input.scopeId), + contactId, + personId, + conversationId, + observedAt, + outcomes, + learningEligible: false, + rawPrivateChatPersisted: false + }; + if (observationWindow) vector.observationWindow = observationWindow; + if (signals) vector.signals = signals; + return deepFreeze(vector); + } + + function bindTrainableOutcome(input = {}) { + const source = input.eligibleSourceSignal || {}; + const vector = input.outcomeVector || {}; + const score = input.score || {}; + const sourceSignalId = clean(source.signal_id || source.signalId); + const sourceDecisionId = clean(source.signal?.decisionRecord?.decisionId); + if ( + !sourceSignalId || clean(source.signal_type || source.signalType) !== 'candidate_sent' || + !(source.learning_eligible === true || source.learning_eligible === 1 || source.learningEligible === true) || + !sourceDecisionId + ) { + throw attributionError('LEARNING_POLICY_ELIGIBLE_SOURCE_SIGNAL_REQUIRED', 'A learning-eligible immutable candidate_sent source signal is required.'); + } + if (vector.signalType !== RAW_SIGNAL_TYPE || vector.learningEligible !== false || clean(vector.decisionId) !== sourceDecisionId) { + throw attributionError('LEARNING_POLICY_OUTCOME_DECISION_BINDING_MISMATCH', 'Raw outcome must remain false-eligible and bind the source DecisionRecord.'); + } + const scoreSourceId = clean(score.eligibleSourceSignalId || score.sourceSignalId); + const outcomeIds = unique((vector.outcomes || []).map(row => row.outcomeId)); + const scoreOutcomeIds = unique(score.outcomeIds); + const exactOutcomeSet = outcomeIds.length === scoreOutcomeIds.length && outcomeIds.every(id => scoreOutcomeIds.includes(id)); + if ( + score.authority !== 'Langfuse' || score.approvedByLearning !== true || !clean(score.scoreId) || + scoreSourceId !== sourceSignalId || clean(score.decisionId) !== sourceDecisionId || + !exactOutcomeSet || !clean(score.outcomeEvidenceSetRef) || !clean(score.rewardPolicyVersion) || + !Number.isFinite(Number(score.value)) + ) { + throw attributionError('LEARNING_POLICY_APPROVED_SCORE_BINDING_REQUIRED', 'Learning-approved Langfuse Score must bind the exact source/decision/outcome evidence set.'); + } + return deepFreeze({ + eligibleSourceSignalId: sourceSignalId, + sourceSignalId, + decisionId: sourceDecisionId, + outcomeIds, + outcomeEvidenceSetRef: clean(score.outcomeEvidenceSetRef), + rewardPolicyVersion: clean(score.rewardPolicyVersion), + outcomeVector: vector, + reward: deepFreeze({ + authority: 'Langfuse', + scoreId: clean(score.scoreId), + value: Number(score.value), + approvedByLearning: true + }) + }); + } + + function eligibleSentSources(conversationId) { + if (!repository || typeof repository.listLearningSignals !== 'function') return []; + const rows = repository.listLearningSignals({ + scopeType: 'conversation', scopeId: conversationId, learningLevel: 'L1', learningEligible: true + }); + return (Array.isArray(rows) ? rows : []).filter(row => + clean(row.signal_type || row.signalType) === 'candidate_sent' && row.signal?.decisionRecord?.decisionId + ); + } + + function resolveInboundAttribution(message = {}) { + const conversationId = clean(message.conversationId || message.sessionKey); + const contactId = clean(message.contactId); + const inboundId = clean(message.externalMessageId || message.messageId || message.id); + const observedAt = timestamp(message.sentAt || message.timestamp || message.createdAt); + if (!conversationId || !contactId || !inboundId || !observedAt) return null; + const direction = clean(message.direction).toLowerCase(); + if (message.fromMe === true || ['outbound', 'outgoing'].includes(direction)) return null; + + const identity = identityAuthority.resolve({ contactId, conversationId }); + if (identity?.authority !== 'PersonContextAuthority' || identity?.found !== true || !clean(identity.personId)) { + throw attributionError('LEARNING_POLICY_OUTCOME_IDENTITY_REQUIRED', 'Inbound attribution requires canonical PersonContextAuthority identity.'); + } + const personId = clean(identity.personId); + const candidates = eligibleSentSources(conversationId).filter(row => { + const decision = row.signal?.decisionRecord || {}; + const sentAt = timestamp(row.created_at || row.createdAt); + return sentAt && sentAt < observedAt + && clean(decision.contactId) === contactId + && clean(decision.conversationId) === conversationId + && clean(decision.personId) === personId; + }).sort((a, b) => timestamp(b.created_at || b.createdAt).localeCompare(timestamp(a.created_at || a.createdAt))); + if (!candidates.length) return null; + const latestAt = timestamp(candidates[0].created_at || candidates[0].createdAt); + const latest = candidates.filter(row => timestamp(row.created_at || row.createdAt) === latestAt); + if (latest.length !== 1) { + throw attributionError('LEARNING_POLICY_OUTCOME_ATTRIBUTION_AMBIGUOUS', 'Inbound outcome matches more than one latest eligible decision.', { conversationId, contactId, inboundId }); + } + return { source: latest[0], identity, inboundId, observedAt }; + } + + function persistInboundOutcome(message = {}) { + const match = resolveInboundAttribution(message); + if (!match) return { skipped: true, reasonCode: 'NO_ELIGIBLE_SENT_DECISION' }; + const source = match.source; + const decision = source.signal.decisionRecord; + const sourceSignalId = clean(source.signal_id || source.signalId); + const sentAt = timestamp(source.created_at || source.createdAt); + const latencyMs = Math.max(0, Date.parse(match.observedAt) - Date.parse(sentAt)); + const messagePairRef = canonicalHash({ sourceSignalId, inboundMessageId: match.inboundId, sentAt, observedAt: match.observedAt }); + const inboundEvidenceRef = canonicalHash({ inboundMessageId: match.inboundId, conversationId: decision.conversationId, contactId: decision.contactId, observedAt: match.observedAt }); + const nextDayWindowEnd = new Date(Date.parse(match.observedAt) + 24 * 60 * 60 * 1000).toISOString(); + const outcomeId = `outcome:${canonicalHash({ decisionId: decision.decisionId, sourceSignalId, inboundMessageId: match.inboundId })}`; + const vector = createOutcomeVector({ + outcomeId, + decisionId: decision.decisionId, + sourceSignalId, + contactId: decision.contactId, + personId: decision.personId, + conversationId: decision.conversationId, + observedAt: match.observedAt, + observationWindow: { start: sentAt, end: match.observedAt }, + outcomes: [ + { outcomeId: `${outcomeId}:reply`, type: 'reply_received', value: 1, evidenceRef: inboundEvidenceRef }, + { outcomeId: `${outcomeId}:continued`, type: 'conversation_continued', value: 1, evidenceRef: messagePairRef } + ], + signals: { + replyLatencyMs: { value: latencyMs, status: 'observed', observedAt: match.observedAt, windowStart: sentAt, windowEnd: match.observedAt, sourceType: 'message-pair', sourceId: `${sourceSignalId}:${match.inboundId}`, provenanceRef: messagePairRef }, + conversationContinued: { value: true, status: 'observed', observedAt: match.observedAt, windowStart: sentAt, windowEnd: match.observedAt, sourceType: 'inbound-message', sourceId: match.inboundId, provenanceRef: inboundEvidenceRef }, + nextDayReinitiation: { value: null, status: 'pending', observedAt: '', windowStart: match.observedAt, windowEnd: nextDayWindowEnd, sourceType: 'conversation-window', sourceId: decision.conversationId, provenanceRef: '' } + } + }); + if (!repository || typeof repository.insertLearningSignal !== 'function') { + return { skipped: true, reasonCode: 'LEARNING_SIGNAL_LEDGER_UNAVAILABLE', outcomeVector: vector }; + } + const idempotencyKey = `policy-outcome:${decision.decisionId}:${sourceSignalId}:${match.inboundId}`; + const persisted = repository.insertLearningSignal({ + signalId: `learning-signal-${canonicalHash({ idempotencyKey }).slice(0, 24)}`, + idempotencyKey, + learningLevel: 'L1', + scopeType: 'conversation', + scopeId: decision.conversationId, + contactId: decision.contactId, + conversationId: decision.conversationId, + candidateId: clean(source.candidate_id || source.candidateId), + outboxId: clean(source.outbox_id || source.outboxId), + signalType: RAW_SIGNAL_TYPE, + signal: vector, + qualityTier: clean(source.quality_tier || source.qualityTier), + emergencyMode: false, + learningEligible: false, + createdAt: match.observedAt + }); + return { skipped: false, persisted, outcomeVector: vector }; + } + + function start() { + if (started) return status(); + listener = event => { + Promise.resolve().then(() => persistInboundOutcome(event?.payload?.message || {})).catch(error => { + bus.publish('learning-policy:outcome-attribution-failed', { + reasonCode: clean(error.reasonCode || error.code) || 'LEARNING_POLICY_OUTCOME_ATTRIBUTION_FAILED', + message: clean(error.message), + conversationId: clean(event?.payload?.message?.conversationId || event?.payload?.message?.sessionKey), + contactId: clean(event?.payload?.message?.contactId) + }); + }); + }; + bus.on('message:inserted', listener); + started = true; + return status(); + } + + function stop() { + if (listener) bus.off('message:inserted', listener); + listener = null; + started = false; + return { stopped: true }; + } + function status() { return Object.freeze({ started, authority: AUTHORITY, signalType: RAW_SIGNAL_TYPE, rawOutcomeLearningEligible: false }); } + + return Object.freeze({ createOutcomeVector, bindTrainableOutcome, resolveInboundAttribution, persistInboundOutcome, start, stop, status }); +} + +const singleton = createLearningOutcomeAttributionService(); +module.exports = { AUTHORITY, RAW_SIGNAL_TYPE, createLearningOutcomeAttributionService, singleton }; diff --git a/backend/services/learningPolicyDecisionContract.js b/backend/services/learningPolicyDecisionContract.js new file mode 100644 index 000000000..13152dee6 --- /dev/null +++ b/backend/services/learningPolicyDecisionContract.js @@ -0,0 +1,201 @@ +'use strict'; + +const { canonicalHash } = require('./canonicalSerialization'); +const personContextAuthority = require('./personContextAuthority').singleton; + +const AUTHORITY = 'LearningPolicyDecisionContract'; +const ACTION_ENCODING_VERSION = 'candidate-strategy-branch-v1'; +const ALLOWED_ACTIONS = Object.freeze([ + 'natural_hook', + 'playful_attraction', + 'direct_advance', + 'screen_and_advance', + 'leave_aftertaste' +]); +const FEATURE_SCHEMA = Object.freeze([ + 'interactionBand', + 'performanceMode', + 'questionPolicy', + 'relationshipStage', + 'targetLanguage' +]); +const PRIVATE_FEATURE_PATTERN = /(raw|chat|message|body|text|memory|name|email|phone|address|credential|api[_-]?key|secret|token|prompt)/iu; +const SAFE_ENUM_TOKEN = /^[\p{L}\p{N}_-]{1,64}$/u; + +function clean(value) { return String(value == null ? '' : value).trim(); } +function policyError(reasonCode, message, details = {}) { + return Object.assign(new Error(message || reasonCode), { reasonCode, code: reasonCode, ...details }); +} +function unique(values = []) { return [...new Set((Array.isArray(values) ? values : []).map(clean).filter(Boolean))]; } +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} + +function normalizeFeatureBundle(input = {}) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw policyError('LEARNING_POLICY_FEATURE_BUNDLE_INVALID', 'Learned Policy featureBundle must be a plain object.'); + } + const keys = Object.keys(input); + for (const key of keys) { + if (PRIVATE_FEATURE_PATTERN.test(key)) { + throw policyError( + 'LEARNING_POLICY_FEATURE_BUNDLE_PRIVATE_BODY_FORBIDDEN', + `Private/free-form feature field ${key} is forbidden.` + ); + } + if (!FEATURE_SCHEMA.includes(key)) { + throw policyError('LEARNING_POLICY_FEATURE_BUNDLE_FIELD_FORBIDDEN', `Feature field ${key} is outside the fixed P1 schema.`); + } + } + const output = {}; + for (const key of FEATURE_SCHEMA) { + if (!Object.prototype.hasOwnProperty.call(input, key)) continue; + const value = input[key]; + if (typeof value === 'boolean') output[key] = value; + else if (typeof value === 'number' && Number.isFinite(value)) output[key] = value; + else { + const token = clean(value); + if (!SAFE_ENUM_TOKEN.test(token)) { + throw policyError('LEARNING_POLICY_FEATURE_BUNDLE_VALUE_INVALID', `Feature ${key} must be a bounded enum/numeric/boolean value.`); + } + output[key] = token; + } + } + return deepFreeze(output); +} + +function normalizeGeneration(input = {}) { + const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {}; + const output = {}; + for (const key of ['modelBrainExecutionId', 'candidatePlanId', 'directorStrategyId', 'contextVersion', 'conversationRevision']) { + if (source[key] == null || source[key] === '') continue; + if (['contextVersion', 'conversationRevision'].includes(key)) { + const numeric = Number(source[key]); + if (Number.isFinite(numeric)) output[key] = numeric; + } else { + const token = clean(source[key]); + if (token) output[key] = token.slice(0, 256); + } + } + return deepFreeze(output); +} + +function createLearningPolicyDecisionContract(options = {}) { + const identityAuthority = options.personContextAuthority || personContextAuthority; + if (!identityAuthority || typeof identityAuthority.resolve !== 'function') { + throw new TypeError('personContextAuthority.resolve is required'); + } + + function createDecisionRecord(input = {}) { + const contactId = clean(input.contactId); + const conversationId = clean(input.conversationId); + const personaProfileId = clean(input.personaProfileId); + if (!contactId || !conversationId || !personaProfileId) { + throw policyError('LEARNING_POLICY_DECISION_SCOPE_REQUIRED', 'contactId, conversationId and personaProfileId are required.'); + } + + const resolved = identityAuthority.resolve({ contactId, conversationId }); + const personId = clean(resolved?.personId); + const contactIds = unique(resolved?.contactIds); + const conversationIds = unique(resolved?.conversationIds); + const expectedPersonId = clean(input.expectedPersonId); + if ( + resolved?.authority !== 'PersonContextAuthority' || resolved?.found !== true || !personId || + !contactIds.includes(contactId) || !conversationIds.includes(conversationId) || + (expectedPersonId && expectedPersonId !== personId) + ) { + throw policyError( + 'LEARNING_POLICY_IDENTITY_BINDING_MISMATCH', + 'Learned Policy decision identity must resolve to one canonical Person/Contact/Conversation binding.', + { contactId, conversationId, resolvedPersonId: personId, expectedPersonId } + ); + } + + const featureBundle = normalizeFeatureBundle(input.featureBundle || {}); + const candidateStrategyBranch = clean(input.candidateStrategyBranch); + const allowedActionSet = unique(input.allowedActionSet?.length ? input.allowedActionSet : ALLOWED_ACTIONS); + if ( + !allowedActionSet.length || + allowedActionSet.some(action => !ALLOWED_ACTIONS.includes(action)) || + !allowedActionSet.includes(candidateStrategyBranch) + ) { + throw policyError('LEARNING_POLICY_ACTION_NOT_ALLOWED', 'candidateStrategyBranch/action-set is outside the exact P1 action authority.', { candidateStrategyBranch, allowedActionSet }); + } + const behaviorPolicyVersion = clean(input.behaviorPolicyVersion || input.policyVersion) || 'vw-p1-baseline-v1'; + const policyVersion = clean(input.policyVersion) || behaviorPolicyVersion; + const policyArtifactId = clean(input.policyArtifactId) || 'baseline'; + const generation = normalizeGeneration(input.generation); + const contributingPolicyVersions = deepFreeze({ + relationship: clean(input.contributingPolicyVersions?.relationship) || 'relationship-current-v1', + memory: clean(input.contributingPolicyVersions?.memory) || 'memory-current-v1', + strategy: clean(input.contributingPolicyVersions?.strategy) || 'director-strategy-current-v1', + candidateRanker: clean(input.contributingPolicyVersions?.candidateRanker) || ACTION_ENCODING_VERSION, + routing: clean(input.contributingPolicyVersions?.routing) || 'model-brain-routing-current-v1', + promptProgram: clean(input.contributingPolicyVersions?.promptProgram) || 'context-aware-reply-current-v1', + behaviorPolicy: behaviorPolicyVersion + }); + const stateSnapshotRef = canonicalHash({ + personId, contactId, conversationId, personaProfileId, featureBundle, + generation, contributingPolicyVersions + }); + const featureSchemaRef = canonicalHash({ schemaVersion: 1, fields: FEATURE_SCHEMA }); + const contextCandidateSetRef = canonicalHash({ + candidatePlanId: generation.candidatePlanId || '', + directorStrategyId: generation.directorStrategyId || '', + actions: allowedActionSet + }); + const actionSetRef = canonicalHash({ encoding: ACTION_ENCODING_VERSION, actions: allowedActionSet }); + const actionId = `candidateStrategyBranch:${candidateStrategyBranch}`; + const recordCore = { + schemaVersion: 1, + authority: AUTHORITY, + scopeType: 'conversation', + scopeId: conversationId, + contactId, + conversationId, + personId, + contactIds, + conversationIds, + personaProfileId, + featureBundle, + stateSnapshotRef, + featureSchemaRef, + contextCandidateSetRef, + actionId, + actionEncodingVersion: ACTION_ENCODING_VERSION, + allowedActionSet: deepFreeze([...allowedActionSet]), + actionSetRef, + chosenAction: { kind: 'candidateStrategyBranch', value: candidateStrategyBranch }, + candidateStrategyBranch, + behaviorPolicyVersion, + policyVersion, + policyArtifactId, + actionProbability: 1, + exploration: false, + generation, + contributingPolicyVersions, + rawPrivateChatPersisted: false + }; + const decisionId = `decision:${canonicalHash(recordCore)}`; + return deepFreeze({ ...recordCore, decisionId }); + } + + return Object.freeze({ + authority: AUTHORITY, + allowedActions: ALLOWED_ACTIONS, + featureSchema: FEATURE_SCHEMA, + createDecisionRecord + }); +} + +module.exports = { + AUTHORITY, + ACTION_ENCODING_VERSION, + ALLOWED_ACTIONS, + FEATURE_SCHEMA, + createLearningPolicyDecisionContract, + normalizeFeatureBundle +}; diff --git a/backend/services/learningPolicyRuntimeAdapter.js b/backend/services/learningPolicyRuntimeAdapter.js new file mode 100644 index 000000000..66ba70e7e --- /dev/null +++ b/backend/services/learningPolicyRuntimeAdapter.js @@ -0,0 +1,236 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const { ALLOWED_ACTIONS, normalizeFeatureBundle } = require('./learningPolicyDecisionContract'); + +const AUTHORITY = 'LearningPolicyRuntimeAdapter'; +const BASELINE_POLICY_VERSION = 'vw-p1-baseline-v1'; +const ACTIVE_FLAG_KEY = 'yance-learning-policy-active'; +const POLICY_VERSION = 'vw-p1-v1'; +const SHA256_RE = /^[0-9a-f]{64}$/u; + +function clean(value) { return String(value == null ? '' : value).trim(); } +function runtimeError(reasonCode, message, details = {}) { + return Object.assign(new Error(message || reasonCode), { reasonCode, code: reasonCode, ...details }); +} +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} +function exactAllowedActions(values) { + const actions = Array.isArray(values) ? values.map(clean).filter(Boolean) : []; + if (!actions.length || actions.some(action => !ALLOWED_ACTIONS.includes(action)) || new Set(actions).size !== actions.length) { + throw runtimeError('LEARNING_POLICY_ACTION_SET_INVALID', 'Learned Policy requires a non-empty exact subset of the frozen P1 action set.'); + } + return actions; +} +function sha256File(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} +function canonicalRoots(env = process.env) { + const dataRoot = clean(env.YANCE_DATA_DIR); + if (!dataRoot || !path.isAbsolute(dataRoot)) return null; + const root = path.join(path.resolve(dataRoot), 'learning', 'learned-policy'); + return Object.freeze({ + root, + flagFile: path.join(root, 'flagd', 'flags.json'), + artifactRoot: path.join(root, 'artifacts') + }); +} +function artifactPathFor(roots, digest) { + if (!roots || !SHA256_RE.test(clean(digest))) return null; + return path.join(roots.artifactRoot, `${digest}.vw`); +} +function validatePolicyCandidate(candidate, roots) { + const value = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : null; + const version = clean(value?.version || value?.policyArtifactVersion); + const id = clean(value?.id || value?.policyArtifactId); + if (!value || !SHA256_RE.test(version) || id !== `policy:${version}`) { + throw runtimeError('LEARNING_POLICY_ACTIVE_IDENTITY_INVALID', 'Active Learning policy must use policy: / identity.'); + } + const artifactPath = artifactPathFor(roots, version); + if (!artifactPath || !fs.existsSync(artifactPath) || !fs.statSync(artifactPath).isFile()) { + throw runtimeError('LEARNING_POLICY_ARTIFACT_MISSING', 'Content-addressed promoted VW artifact is missing.'); + } + const actual = sha256File(artifactPath); + if (actual !== version) { + throw runtimeError('LEARNING_POLICY_ARTIFACT_IDENTITY_MISMATCH', 'Promoted VW artifact bytes do not match the promoted content address.', { expected: version, actual }); + } + return Object.freeze({ + policyArtifactId: version, + policyVersion: clean(value.policyVersion) || POLICY_VERSION, + artifactPath + }); +} + +let flagdClientPromise = null; +let flagdClientPath = ''; +async function flagdClientFor(flagFile) { + if (flagdClientPromise && flagdClientPath === flagFile) return flagdClientPromise; + flagdClientPath = flagFile; + flagdClientPromise = (async () => { + const { OpenFeature } = require('@openfeature/server-sdk'); + const { FlagdProvider } = require('@openfeature/flagd-provider'); + const domain = 'yance-learning-policy'; + await OpenFeature.setProviderAndWait(domain, new FlagdProvider({ + resolverType: 'in-process', + offlineFlagSourcePath: flagFile + })); + return OpenFeature.getClient(domain); + })(); + try { + return await flagdClientPromise; + } catch (error) { + flagdClientPromise = null; + flagdClientPath = ''; + throw error; + } +} + +async function resolveProductionActivePolicy() { + const roots = canonicalRoots(); + if (!roots || !fs.existsSync(roots.flagFile)) return null; + const client = await flagdClientFor(roots.flagFile); + const rollout = await client.getObjectValue(ACTIVE_FLAG_KEY, null); + if (!rollout || rollout.kind !== 'LEARNING_ROLLOUT') return null; + const candidates = [rollout.candidate, ...(Array.isArray(rollout.history) ? rollout.history : [])]; + let firstError = null; + for (const candidate of candidates) { + try { + return validatePolicyCandidate(candidate, roots); + } catch (error) { + firstError ||= error; + } + } + if (firstError) throw firstError; + return null; +} + +function sealedLearningRuntimePaths() { + const resourcesRoot = clean(process.resourcesPath); + if (!resourcesRoot || !path.isAbsolute(resourcesRoot)) return null; + const runtimeRoot = path.join(resourcesRoot, 'learning-runtime'); + const python = path.join(runtimeRoot, 'venv', 'Scripts', 'python.exe'); + const entrypoint = path.join(runtimeRoot, 'learning_entrypoint.py'); + if (!fs.existsSync(python) || !fs.existsSync(entrypoint)) return null; + return Object.freeze({ python, entrypoint }); +} +function invokeProductionVowpalWabbit(input = {}) { + const runtime = sealedLearningRuntimePaths(); + if (!runtime) throw runtimeError('SEALED_VW_RUNTIME_UNAVAILABLE', 'Packaged sealed Learning/VW runtime is unavailable.'); + const artifactPath = clean(input.artifactPath); + if (!artifactPath || !path.isAbsolute(artifactPath)) throw runtimeError('LEARNING_POLICY_ARTIFACT_PATH_INVALID', 'Policy artifact path must be canonical and absolute.'); + const request = { + operation: 'policy_predict', + featureBundle: input.featureBundle, + allowedActions: input.allowedActions, + artifactPath, + policyArtifactId: input.policyArtifactId, + policyVersion: input.policyVersion || POLICY_VERSION + }; + const result = spawnSync(runtime.python, ['-I', runtime.entrypoint], { + input: JSON.stringify(request), + encoding: 'utf8', + windowsHide: true, + timeout: 15000, + env: { ...process.env, HTTP_PROXY: 'http://127.0.0.1:9', HTTPS_PROXY: 'http://127.0.0.1:9', ALL_PROXY: 'http://127.0.0.1:9', NO_PROXY: '127.0.0.1,localhost' } + }); + if (result.error) throw result.error; + let parsed = null; + try { parsed = JSON.parse(clean(result.stdout) || '{}'); } catch (_) {} + if (result.status !== 0 || parsed?.status === 'ERROR') { + throw runtimeError('SEALED_VW_POLICY_PREDICTION_FAILED', clean(parsed?.error || result.stderr || `sealed VW runtime exit ${result.status}`)); + } + return parsed; +} + +function createLearningPolicyRuntimeAdapter(options = {}) { + const hasInjectedRuntime = typeof options.invokeVowpalWabbit === 'function'; + const hasInjectedResolver = typeof options.resolveActivePolicy === 'function'; + const invokeVowpalWabbit = hasInjectedRuntime ? options.invokeVowpalWabbit : invokeProductionVowpalWabbit; + const resolveActivePolicy = hasInjectedResolver ? options.resolveActivePolicy : resolveProductionActivePolicy; + const onDegradation = typeof options.onDegradation === 'function' ? options.onDegradation : null; + + function baseline(input, reasonCode = 'NO_PROMOTED_POLICY') { + const actions = exactAllowedActions(input.allowedActions); + const requested = clean(input.baselineAction); + const candidateStrategyBranch = actions.includes(requested) ? requested : actions[0]; + return deepFreeze({ + authority: AUTHORITY, + candidateStrategyBranch, + policyVersion: BASELINE_POLICY_VERSION, + policyArtifactId: 'baseline', + actionProbability: 1, + exploration: false, + degradation: reasonCode === 'NO_PROMOTED_POLICY' ? null : { reasonCode }, + executedPolicy: 'baseline' + }); + } + + async function selectLearnedPolicyAction(input = {}) { + const featureBundle = normalizeFeatureBundle(input.featureBundle || {}); + const allowedActions = exactAllowedActions(input.allowedActions || ALLOWED_ACTIONS); + let activePolicy = null; + try { + activePolicy = await resolveActivePolicy({ featureBundle, allowedActions }); + } catch (error) { + const reasonCode = clean(error?.reasonCode || error?.code) || 'LEARNING_POLICY_ACTIVE_RESOLUTION_FAILED'; + onDegradation?.({ reasonCode, message: clean(error?.message) }); + if (input.failClosed === true) throw error; + return baseline({ ...input, allowedActions }, reasonCode); + } + // Test/UAT may inject the sealed operation directly; production never accepts + // request-supplied active policy, artifact path, hash, or executable authority. + if (!activePolicy && !hasInjectedRuntime) return baseline({ ...input, allowedActions }, 'NO_PROMOTED_POLICY'); + + try { + const result = await invokeVowpalWabbit({ + operation: 'policy_predict', + featureBundle, + allowedActions, + policyArtifactId: clean(activePolicy?.policyArtifactId), + policyVersion: clean(activePolicy?.policyVersion) || POLICY_VERSION, + artifactPath: clean(activePolicy?.artifactPath) + }); + const action = clean(result?.action || result?.candidateStrategyBranch); + if (!allowedActions.includes(action)) { + throw runtimeError('LEARNING_POLICY_RUNTIME_ACTION_INVALID', 'Sealed VW runtime returned an action outside the supplied exact action set.', { action }); + } + const probability = Number(result?.probability ?? result?.actionProbability ?? 1); + if (probability !== 1 || result?.exploration === true) { + throw runtimeError('LEARNING_POLICY_RUNTIME_P1_NONDETERMINISTIC', 'P1 Learned Policy must remain deterministic with probability 1 and exploration disabled.'); + } + return deepFreeze({ + authority: AUTHORITY, + candidateStrategyBranch: action, + policyVersion: clean(result?.policyVersion || activePolicy?.policyVersion) || POLICY_VERSION, + policyArtifactId: clean(result?.policyArtifactId || result?.policyArtifactVersion || activePolicy?.policyArtifactId) || 'baseline', + actionProbability: 1, + exploration: false, + degradation: null, + executedPolicy: 'vowpalwabbit' + }); + } catch (error) { + const reasonCode = clean(error?.reasonCode || error?.code) || 'SEALED_VW_POLICY_PREDICTION_FAILED'; + onDegradation?.({ reasonCode, policyArtifactId: clean(activePolicy?.policyArtifactId), message: clean(error?.message) }); + if (input.failClosed === true) throw error; + return baseline({ ...input, allowedActions }, reasonCode); + } + } + + return Object.freeze({ authority: AUTHORITY, selectLearnedPolicyAction, baseline }); +} + +module.exports = { + AUTHORITY, + BASELINE_POLICY_VERSION, + ACTIVE_FLAG_KEY, + createLearningPolicyRuntimeAdapter, + resolveProductionActivePolicy, + invokeProductionVowpalWabbit +}; diff --git a/backend/services/learningPromotionAdapter.js b/backend/services/learningPromotionAdapter.js index 3779c1bae..b54c62f01 100644 --- a/backend/services/learningPromotionAdapter.js +++ b/backend/services/learningPromotionAdapter.js @@ -1,17 +1,110 @@ 'use strict'; -function promotionError(reasonCode, message) { - const error = new Error(message); - error.reasonCode = reasonCode; - return error; +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const ACTIVE_FLAG_KEY = 'yance-learning-policy-active'; +const SHA256_RE = /^[0-9a-f]{64}$/u; + +function promotionError(reasonCode, message, details = {}) { + return Object.assign(new Error(message), { reasonCode, code: reasonCode, ...details }); +} +function clean(value) { return String(value == null ? '' : value).trim(); } +function sha256File(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } +function canonicalRoots(env = process.env) { + const dataRoot = clean(env.YANCE_DATA_DIR); + if (!dataRoot || !path.isAbsolute(dataRoot)) { + throw promotionError('YANCE_DATA_DIR_REQUIRED', 'Production Learning promotion requires an absolute YANCE_DATA_DIR.'); + } + const root = path.join(path.resolve(dataRoot), 'learning', 'learned-policy'); + return Object.freeze({ + root, + flagRoot: path.join(root, 'flagd'), + flagFile: path.join(root, 'flagd', 'flags.json'), + candidateRoot: path.join(root, 'candidates'), + artifactRoot: path.join(root, 'artifacts') + }); +} +function atomicWriteJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temp = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`; + fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + fs.renameSync(temp, filePath); +} +function candidateIdentity(candidate = {}) { + const version = clean(candidate.version); + const id = clean(candidate.id); + if (!SHA256_RE.test(version) || id !== `policy:${version}`) { + throw promotionError('LEARNING_PROMOTION_CANDIDATE_IDENTITY_INVALID', 'Candidate must use policy: / content identity.'); + } + return Object.freeze({ id, version, policyVersion: clean(candidate.policyVersion) || 'vw-p1-v1' }); +} +function nativeFlagDocument(rollout) { + return { + flags: { + [ACTIVE_FLAG_KEY]: { + state: 'ENABLED', + variants: { active: rollout }, + defaultVariant: 'active' + } + } + }; +} +function readNativeRollout(flagFile) { + if (!fs.existsSync(flagFile)) return null; + const doc = JSON.parse(fs.readFileSync(flagFile, 'utf8')); + return doc?.flags?.[ACTIVE_FLAG_KEY]?.variants?.active || null; +} +function materializeCandidate(roots, candidate) { + fs.mkdirSync(roots.artifactRoot, { recursive: true }); + const source = path.join(roots.candidateRoot, `${candidate.version}.vw`); + const destination = path.join(roots.artifactRoot, `${candidate.version}.vw`); + if (fs.existsSync(destination)) { + const existingHash = sha256File(destination); + if (existingHash !== candidate.version) { + throw promotionError('LEARNING_PROMOTION_ARTIFACT_IDENTITY_MISMATCH', 'Existing promoted artifact bytes do not match candidate identity.'); + } + return destination; + } + if (!fs.existsSync(source) || !fs.statSync(source).isFile()) { + throw promotionError('LEARNING_PROMOTION_CANONICAL_CANDIDATE_MISSING', 'Canonical Learning candidate artifact is missing.'); + } + const actual = sha256File(source); + if (actual !== candidate.version) { + throw promotionError('LEARNING_PROMOTION_ARTIFACT_IDENTITY_MISMATCH', 'Candidate artifact bytes do not match candidate identity.', { expected: candidate.version, actual }); + } + const temp = `${destination}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`; + fs.copyFileSync(source, temp, fs.constants.COPYFILE_EXCL); + if (sha256File(temp) !== candidate.version) { + fs.rmSync(temp, { force: true }); + throw promotionError('LEARNING_PROMOTION_ARTIFACT_COPY_MISMATCH', 'Copied candidate artifact failed content-address verification.'); + } + fs.renameSync(temp, destination); + return destination; +} +async function verifyNativeFlagd(flagFile, expectedVersion) { + const { OpenFeature } = require('@openfeature/server-sdk'); + const { FlagdProvider } = require('@openfeature/flagd-provider'); + const domain = 'yance-learning-policy-promotion'; + await OpenFeature.setProviderAndWait(domain, new FlagdProvider({ + resolverType: 'in-process', + offlineFlagSourcePath: flagFile + })); + const evaluated = await OpenFeature.getClient(domain).getObjectValue(ACTIVE_FLAG_KEY, null); + if (!evaluated || clean(evaluated?.candidate?.version) !== expectedVersion) { + throw promotionError('LEARNING_PROMOTION_FLAGD_VERIFICATION_FAILED', 'Native flagd activation did not resolve the promoted candidate.'); + } + return evaluated; } function createLearningPromotionAdapter(options = {}) { - const openFeature = options.openFeature || null; // OpenFeature authority. - const flagd = options.flagd || null; // flagd in-process/offline provider authority. + const openFeature = options.openFeature || null; + const flagd = options.flagd || null; const langfuse = options.langfuse || null; + const injectedAuthority = Boolean(openFeature || flagd); - function requireRolloutAuthority() { + function requireInjectedRolloutAuthority() { if (!openFeature || typeof openFeature.setEvaluationContext !== 'function') { throw promotionError('OPENFEATURE_UNAVAILABLE', 'OpenFeature runtime is required for staged rollout.'); } @@ -24,15 +117,41 @@ function createLearningPromotionAdapter(options = {}) { if (input.approved !== true) throw promotionError('LEARNING_APPROVAL_REQUIRED', 'Explicit approval is required before Promotion.'); if (proposal.status !== 'READY_FOR_REVIEW') throw promotionError('LEARNING_EVALUATION_INCOMPLETE', 'Regression and Shadow evidence must pass before Promotion.'); if (!proposal.Regression?.passed || !proposal.Shadow?.passed) throw promotionError('LEARNING_EVIDENCE_REJECTED', 'Regression and Shadow must both pass.'); - requireRolloutAuthority(); + const candidate = candidateIdentity(proposal.Candidate || {}); + const evidenceId = clean(input.evidence?.id); + if (!evidenceId) throw promotionError('LEARNING_PROMOTION_EVIDENCE_REQUIRED', 'Promotion evidence is required.'); + + if (injectedAuthority) { + // Explicit test/UAT seam retained for existing contracts. It is not the + // production-default activation path and does not claim V3 closure. + requireInjectedRolloutAuthority(); + const rollout = Object.freeze({ + kind: 'LEARNING_ROLLOUT', candidate: proposal.Candidate, approvedAt: new Date().toISOString(), + OpenFeature: true, flagd: 'in-process-offline', automaticPromotion: false + }); + await langfuse?.recordPromotion?.({ proposal, rollout }); + return rollout; + } + + const roots = canonicalRoots(); + materializeCandidate(roots, candidate); + const previous = readNativeRollout(roots.flagFile); + const previousCandidates = [previous?.candidate, ...(Array.isArray(previous?.history) ? previous.history : [])] + .filter(row => row && clean(row.version) && clean(row.version) !== candidate.version) + .slice(0, 8) + .map(row => ({ id: clean(row.id), version: clean(row.version), policyVersion: clean(row.policyVersion) || 'vw-p1-v1' })); const rollout = Object.freeze({ kind: 'LEARNING_ROLLOUT', - candidate: proposal.Candidate, + candidate, + history: Object.freeze(previousCandidates), + evidenceId, approvedAt: new Date().toISOString(), OpenFeature: true, flagd: 'in-process-offline', automaticPromotion: false }); + atomicWriteJson(roots.flagFile, nativeFlagDocument(rollout)); + await verifyNativeFlagd(roots.flagFile, candidate.version); await langfuse?.recordPromotion?.({ proposal, rollout }); return rollout; } @@ -41,27 +160,42 @@ function createLearningPromotionAdapter(options = {}) { if (input.approved !== true) { throw promotionError('LEARNING_ROLLBACK_APPROVAL_REQUIRED', 'Explicit Learning approval is required before rollback.'); } - const evidenceId = String(input.evidence?.id || '').trim(); - if (!evidenceId) { - throw promotionError('LEARNING_ROLLBACK_EVIDENCE_REQUIRED', 'Explicit rollback evidence is required.'); - } - if (rollout.kind !== 'LEARNING_ROLLOUT') { - throw promotionError('LEARNING_ROLLBACK_ROLLOUT_REQUIRED', 'Rollback requires a canonical Learning rollout.'); + const evidenceId = clean(input.evidence?.id); + if (!evidenceId) throw promotionError('LEARNING_ROLLBACK_EVIDENCE_REQUIRED', 'Explicit rollback evidence is required.'); + if (rollout.kind !== 'LEARNING_ROLLOUT') throw promotionError('LEARNING_ROLLBACK_ROLLOUT_REQUIRED', 'Rollback requires a canonical Learning rollout.'); + if (!clean(rollout.candidate?.id)) throw promotionError('LEARNING_ROLLBACK_CANDIDATE_REQUIRED', 'Rollback requires the Learning rollout candidate identity.'); + + if (injectedAuthority) { + requireInjectedRolloutAuthority(); + const receipt = Object.freeze({ + kind: 'LEARNING_ROLLBACK', rollout, candidate: rollout.candidate, evidenceId, + rolledBackAt: new Date().toISOString(), OpenFeature: true, flagd: 'in-process-offline', automaticPromotion: false + }); + await langfuse?.recordRollback?.({ rollout, evidence: input.evidence, receipt }); + return receipt; } - const candidateId = String(rollout.candidate?.id || '').trim(); - if (!candidateId) { - throw promotionError('LEARNING_ROLLBACK_CANDIDATE_REQUIRED', 'Rollback requires the Learning rollout candidate identity.'); + + const roots = canonicalRoots(); + const canonical = readNativeRollout(roots.flagFile); + const history = Array.isArray(canonical?.history) ? canonical.history : []; + const previous = history[0] ? candidateIdentity(history[0]) : null; + if (previous) { + const previousPath = path.join(roots.artifactRoot, `${previous.version}.vw`); + if (!fs.existsSync(previousPath) || sha256File(previousPath) !== previous.version) { + throw promotionError('LEARNING_ROLLBACK_LAST_KNOWN_GOOD_INVALID', 'Rollback target is not a verified content-addressed promoted artifact.'); + } + const next = Object.freeze({ + kind: 'LEARNING_ROLLOUT', candidate: previous, history: Object.freeze(history.slice(1)), + evidenceId, approvedAt: new Date().toISOString(), OpenFeature: true, flagd: 'in-process-offline', automaticPromotion: false + }); + atomicWriteJson(roots.flagFile, nativeFlagDocument(next)); + await verifyNativeFlagd(roots.flagFile, previous.version); + } else { + atomicWriteJson(roots.flagFile, { flags: {} }); } - requireRolloutAuthority(); const receipt = Object.freeze({ - kind: 'LEARNING_ROLLBACK', - rollout, - candidate: rollout.candidate, - evidenceId, - rolledBackAt: new Date().toISOString(), - OpenFeature: true, - flagd: 'in-process-offline', - automaticPromotion: false + kind: 'LEARNING_ROLLBACK', rollout, candidate: rollout.candidate, restoredCandidate: previous, + evidenceId, rolledBackAt: new Date().toISOString(), OpenFeature: true, flagd: 'in-process-offline', automaticPromotion: false }); await langfuse?.recordRollback?.({ rollout, evidence: input.evidence, receipt }); return receipt; @@ -70,4 +204,4 @@ function createLearningPromotionAdapter(options = {}) { return Object.freeze({ promote, rollback, authority: 'OpenFeature + flagd; Langfuse evidence' }); } -module.exports = { createLearningPromotionAdapter }; +module.exports = { createLearningPromotionAdapter, ACTIVE_FLAG_KEY }; diff --git a/backend/services/replyFeedbackLearningService.js b/backend/services/replyFeedbackLearningService.js index f2438939b..536ede6c0 100644 --- a/backend/services/replyFeedbackLearningService.js +++ b/backend/services/replyFeedbackLearningService.js @@ -37,6 +37,10 @@ function buildImmutableFeedbackSignal(input = {}) { const evidenceKey = clean(input.evidenceId) || (eventType === 'sent' ? outboxId : candidateId); const idempotencyKey = 'reply-feedback:' + eventType + ':' + evidenceKey; const branch = input.candidateStrategyBranch || metadata.candidateStrategyBranch || {}; + const decisionRecord = input.decisionRecord || metadata.decisionRecord || null; + const immutableDecisionRecord = decisionRecord && typeof decisionRecord === 'object' && !Array.isArray(decisionRecord) + ? Object.freeze({ ...decisionRecord }) + : null; return Object.freeze({ skipped: false, signalId: signalId(idempotencyKey), @@ -61,6 +65,7 @@ function buildImmutableFeedbackSignal(input = {}) { hasExplicitRejectionReason: input.hasExplicitRejectionReason === true, adjustments: normalizeAdjustments(metadata, input.styleVariant), strategyBranch: clean(metadata.candidateStrategyBranchId || branch.strategy), + ...(immutableDecisionRecord ? { decisionRecord: immutableDecisionRecord } : {}), metadata: Object.freeze({ source: clean(input.source) || 'reply-outcome-transaction', replyTask: clean(input.replyTask || metadata.replyTask), @@ -118,4 +123,4 @@ function status() { }); } -module.exports = { AUTHORITY, start, stop, status, waitForIdle, isLearningDisabled, normalizeAdjustments, buildImmutableFeedbackSignal, persistImmutableLearningSignal }; +module.exports = { AUTHORITY, start, stop, status, waitForIdle, isLearningDisabled, normalizeAdjustments, buildImmutableFeedbackSignal, persistImmutableLearningSignal }; \ No newline at end of file diff --git a/backend/services/storeManagerService.js b/backend/services/storeManagerService.js index c860aeaae..51e2bf592 100644 --- a/backend/services/storeManagerService.js +++ b/backend/services/storeManagerService.js @@ -11,6 +11,7 @@ const { registerRuntimeStateCommands } = require('../store/commands/registerRunt const { StoreIntegrityMonitor } = require('../store/StoreIntegrityMonitor'); const typingStateService = require('./typingStateService'); const replyFeedbackLearningService = require('./replyFeedbackLearningService'); +const learningOutcomeAttributionService = require('./learningOutcomeAttributionService').singleton; const conversationTurnCoordinator = require('./conversationTurnCoordinator'); const aiTaskRuntimeRegistry = require('./aiTaskRuntimeRegistry'); const personaBrainModule = require('../personaBrain'); @@ -101,6 +102,10 @@ async function initialize(options = {}) { }); conversationTurnCoordinator.start(); replyFeedbackLearningService.start({ storeManager, personaBrain }); + // Outcome attribution deliberately starts only after authoritative StoreManager + // hydration. It reuses the existing message:inserted event and immutable + // learning_signal_ledger; it owns no second inbound pipeline or scheduler. + learningOutcomeAttributionService.start(); integrityMonitor = new StoreIntegrityMonitor({ storeManager, logger, @@ -135,6 +140,7 @@ function status() { } function stop() { + learningOutcomeAttributionService.stop(); replyFeedbackLearningService.stop(); conversationTurnCoordinator.stop(); typingStateService.stop(); @@ -143,4 +149,4 @@ function stop() { started = false; } -module.exports = { initialize, status, stop, ensureCustomerContext }; +module.exports = { initialize, status, stop, ensureCustomerContext }; \ No newline at end of file diff --git a/config/upstreams/v21-learning-growth-brain-p0.json b/config/upstreams/v21-learning-growth-brain-p0.json index 548a6e240..53fe96b6f 100644 --- a/config/upstreams/v21-learning-growth-brain-p0.json +++ b/config/upstreams/v21-learning-growth-brain-p0.json @@ -8,6 +8,7 @@ "opentelemetryPython": { "repository": "open-telemetry/opentelemetry-python", "version": "1.44.0", "commit": "53a5a40c9604583c501bcf13970a635f00e62df4", "license": "Apache-2.0" }, "dspy": { "repository": "stanfordnlp/dspy", "version": "3.3.0", "commit": "e4e97aae29b8ad8aa2fb7e99ffae6fd52970fad8", "license": "MIT" }, "gepa": { "repository": "gepa-ai/gepa", "version": "0.1.1", "commit": "b4dbb55b7601dac448cdb836d5a401ca7d9eb920", "license": "MIT" }, + "vowpalWabbit": { "repository": "VowpalWabbit/vowpal_wabbit", "version": "9.11.2", "commit": "122bae254a5b8bc2b774d13b33d53e6dbc2cfba7", "license": "BSD-3-Clause", "mode": "contextual-bandit-adf-offline-candidate-policy" }, "promptfoo": { "repository": "promptfoo/promptfoo", "version": "0.122.0", "commit": "7b898cbdb16205cb7f0e2994baa807d131eb2326", "license": "MIT", "mode": "precomputed-provider-only" }, "apscheduler": { "repository": "agronholm/apscheduler", "version": "3.11.3", "commit": "4308ec95b94069f5dbdddb6c60fb792dfc8c40a4", "license": "MIT" }, "presidio": { "repository": "microsoft/presidio", "version": "2.2.364", "commit": "779dbd286d5ef4d1fbe2514275fb1bce358f2417", "license": "MIT", "p0Nlp": "NoOpNlpEngine" }, diff --git a/runtime/learning-growth/python/learning_entrypoint.py b/runtime/learning-growth/python/learning_entrypoint.py index b606e8992..a479d3aad 100644 --- a/runtime/learning-growth/python/learning_entrypoint.py +++ b/runtime/learning-growth/python/learning_entrypoint.py @@ -2,15 +2,23 @@ """Thin Learning Growth Brain OSS composition entrypoint. DSPy + GEPA own optimization, APScheduler owns scheduling, Presidio owns -source-side PII minimization, and all live model execution remains delegated -to Yance Model Brain V4. This process never owns model provider credentials. +source-side PII minimization, Vowpal Wabbit owns the bounded learned-policy +action head, and all live model execution remains delegated to Yance Model +Brain V4. This process never owns model provider credentials or reply text. """ from __future__ import annotations +import hashlib import json +import math import sys from dataclasses import dataclass -from typing import Any, Mapping +from importlib.metadata import version as package_version +from pathlib import Path +from typing import Any, Mapping, Sequence + +LEARNED_POLICY_ACTION_ENCODING = "candidate-strategy-branch-v1" +LEARNED_POLICY_FEATURES = ("interactionBand", "performanceMode", "questionPolicy", "relationshipStage", "targetLanguage") @dataclass(frozen=True) @@ -58,18 +66,238 @@ def optimizer_contract() -> dict[str, str]: } +def _assert_allowed_request_fields(payload: Mapping[str, Any], allowed: set[str]) -> None: + unexpected = set(payload.keys()) - allowed + if unexpected: + # Never echo unknown request keys: the sealed action head accepts only + # its exact bounded schema and does not inspect arbitrary extensions. + raise ValueError(f"LEARNED_POLICY_REQUEST_SCHEMA_MISMATCH:{len(unexpected)}") + + +def _safe_token(value: Any) -> str: + text = str(value if value is not None else "").strip() + if not text or len(text) > 64 or any(ch.isspace() for ch in text): + raise ValueError("LEARNED_POLICY_FEATURE_VALUE_INVALID") + return "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in text) + + +def _feature_bundle(value: Any) -> dict[str, Any]: + if not isinstance(value, Mapping): + raise ValueError("LEARNED_POLICY_FEATURE_BUNDLE_INVALID") + unknown = sorted(set(value.keys()) - set(LEARNED_POLICY_FEATURES)) + if unknown: + raise ValueError("LEARNED_POLICY_FEATURE_FIELD_FORBIDDEN:" + ",".join(map(str, unknown))) + result: dict[str, Any] = {} + for key in LEARNED_POLICY_FEATURES: + if key not in value: + continue + item = value[key] + if isinstance(item, bool): + result[key] = item + elif isinstance(item, (int, float)) and not isinstance(item, bool): + numeric = float(item) + if not math.isfinite(numeric): + raise ValueError("LEARNED_POLICY_FEATURE_VALUE_INVALID") + result[key] = numeric + else: + result[key] = _safe_token(item) + return result + + +def _actions(value: Any) -> list[str]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + raise ValueError("LEARNED_POLICY_ACTION_SET_INVALID") + actions = [_safe_token(item) for item in value] + if not actions or len(actions) != len(set(actions)): + raise ValueError("LEARNED_POLICY_ACTION_SET_INVALID") + return actions + + +def _shared_features(features: Mapping[str, Any]) -> str: + tokens: list[str] = [] + for key in LEARNED_POLICY_FEATURES: + if key not in features: + continue + value = features[key] + if isinstance(value, bool): + tokens.append(f"{key}_{str(value).lower()}") + elif isinstance(value, (int, float)) and not isinstance(value, bool): + tokens.append(f"{key}:{float(value)}") + else: + tokens.append(f"{key}_{_safe_token(value)}") + return " ".join(tokens) or "bias" + + +def _adf_lines( + features: Mapping[str, Any], + actions: Sequence[str], + chosen_action: str = "", + cost: float | None = None, + probability: float | None = None, +) -> list[str]: + lines = [f"shared |c {_shared_features(features)}"] + for action in actions: + prefix = "" + if action == chosen_action: + if cost is None or probability is None: + raise ValueError("LEARNED_POLICY_LOGGED_FEEDBACK_REQUIRED") + prefix = f"0:{cost}:{probability} " + lines.append(f"{prefix}|a action_{_safe_token(action)}") + return lines + + +def _artifact_sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _policy_rows(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]: + rows = payload.get("rows") + if not isinstance(rows, list) or not rows: + raise ValueError("LEARNED_POLICY_TRAINING_ROWS_REQUIRED") + return [row for row in rows if isinstance(row, Mapping)] + + +def policy_runtime_contract() -> dict[str, Any]: + import vowpalwabbit + + return { + "status": "READY", + "authority": "Vowpal Wabbit", + "vowpalwabbit": package_version("vowpalwabbit"), + "mode": "contextual-bandit-adf-offline-candidate-policy", + "actionEncodingVersion": LEARNED_POLICY_ACTION_ENCODING, + "operations": ["policy_runtime_contract", "policy_train", "policy_predict"], + "exploration": False, + "textGeneration": False, + "workspace": getattr(vowpalwabbit, "Workspace").__name__, + } + + +def policy_train(payload: Mapping[str, Any]) -> dict[str, Any]: + _assert_allowed_request_fields(payload, {"operation", "rows", "artifactPath"}) + from vowpalwabbit import Workspace + + artifact_path = Path(str(payload.get("artifactPath") or "").strip()) + if not str(artifact_path) or str(artifact_path) == ".": + raise ValueError("LEARNED_POLICY_ARTIFACT_PATH_REQUIRED") + artifact_path.parent.mkdir(parents=True, exist_ok=True) + workspace = Workspace(arg_list=["--cb_adf", "--rank_all", "--quiet"]) + learned = 0 + try: + for row in _policy_rows(payload): + decision = row.get("decision") if isinstance(row.get("decision"), Mapping) else {} + features = _feature_bundle(row.get("featureBundle") or decision.get("featureBundle") or {}) + actions = _actions(decision.get("allowedActionSet") or row.get("allowedActions") or []) + chosen = str(decision.get("candidateStrategyBranch") or decision.get("chosenAction", {}).get("value") or "").strip() + if chosen not in actions: + raise ValueError("LEARNED_POLICY_CHOSEN_ACTION_INVALID") + probability = float(decision.get("actionProbability")) + if not math.isfinite(probability) or probability <= 0 or probability > 1: + raise ValueError("LEARNED_POLICY_LOGGED_PROPENSITY_INVALID") + score = row.get("approvedScore") if isinstance(row.get("approvedScore"), Mapping) else row.get("score") + if not isinstance(score, Mapping) or score.get("approvedByLearning") is not True: + raise ValueError("LEARNED_POLICY_APPROVED_SCORE_REQUIRED") + approved_score = float(score.get("value")) + if not math.isfinite(approved_score): + raise ValueError("LEARNED_POLICY_SCORE_NONFINITE") + cost = -float(approved_score) + workspace.learn(_adf_lines(features, actions, chosen, cost, probability)) + learned += 1 + workspace.save(str(artifact_path)) + finally: + workspace.finish() + digest = _artifact_sha256(artifact_path) + return { + "status": "READY", + "rowCount": learned, + "policyArtifactVersion": digest, + "policyArtifactId": digest, + "actionEncodingVersion": LEARNED_POLICY_ACTION_ENCODING, + "rewardToCost": "cost=-float(approved_score)", + "probability": 1.0, + "exploration": False, + } + + +def _prediction_action_index(prediction: Any, action_count: int) -> int: + if isinstance(prediction, list) and prediction: + first = prediction[0] + if hasattr(first, "action"): + index = int(first.action) + return index if 0 <= index < action_count else index - 1 + if isinstance(first, (tuple, list)) and first: + index = int(first[0]) + return index if 0 <= index < action_count else index - 1 + if all(isinstance(value, (int, float)) for value in prediction): + return min(range(len(prediction)), key=lambda idx: float(prediction[idx])) + if isinstance(prediction, int): + return prediction if 0 <= prediction < action_count else prediction - 1 + raise ValueError("LEARNED_POLICY_PREDICTION_INVALID") + + +def policy_predict(payload: Mapping[str, Any]) -> dict[str, Any]: + _assert_allowed_request_fields(payload, { + "operation", "featureBundle", "allowedActions", "artifactPath", + "policyArtifactId", "policyArtifactVersion", "policyVersion" + }) + from vowpalwabbit import Workspace + + artifact_path = Path(str(payload.get("artifactPath") or "").strip()) + if not artifact_path.is_file(): + raise ValueError("LEARNED_POLICY_ARTIFACT_MISSING") + digest = _artifact_sha256(artifact_path) + expected = str(payload.get("policyArtifactId") or payload.get("policyArtifactVersion") or "").strip() + if expected and expected != digest: + raise ValueError("LEARNED_POLICY_ARTIFACT_IDENTITY_MISMATCH") + features = _feature_bundle(payload.get("featureBundle") or {}) + actions = _actions(payload.get("allowedActions") or []) + workspace = Workspace(arg_list=["--cb_adf", "--rank_all", "--quiet", "-i", str(artifact_path)]) + try: + prediction = workspace.predict(_adf_lines(features, actions)) + finally: + workspace.finish() + index = _prediction_action_index(prediction, len(actions)) + if index < 0 or index >= len(actions): + raise ValueError("LEARNED_POLICY_PREDICTION_ACTION_OUT_OF_RANGE") + return { + "status": "READY", + "action": actions[index], + "candidateStrategyBranch": actions[index], + "policyArtifactId": digest, + "policyArtifactVersion": digest, + "policyVersion": str(payload.get("policyVersion") or "vw-p1-v1"), + "probability": 1.0, + "exploration": False, + "textGeneration": False, + } + + def main() -> int: request = json.load(sys.stdin) operation = str(request.get("operation") or "evaluate") - if operation == "runtime_contract": - json.dump(optimizer_contract(), sys.stdout, sort_keys=True) - return 0 - if operation == "evaluate": - json.dump(evaluate_precomputed(request), sys.stdout, sort_keys=True) - return 0 - json.dump({"status": "UNSUPPORTED_OPERATION", "operation": operation}, sys.stdout, sort_keys=True) - return 2 + try: + if operation == "runtime_contract": + json.dump(optimizer_contract(), sys.stdout, sort_keys=True) + return 0 + if operation == "evaluate": + json.dump(evaluate_precomputed(request), sys.stdout, sort_keys=True) + return 0 + if operation == "policy_runtime_contract": + _assert_allowed_request_fields(request, {"operation"}) + json.dump(policy_runtime_contract(), sys.stdout, sort_keys=True) + return 0 + if operation == "policy_train": + json.dump(policy_train(request), sys.stdout, sort_keys=True) + return 0 + if operation == "policy_predict": + json.dump(policy_predict(request), sys.stdout, sort_keys=True) + return 0 + json.dump({"status": "UNSUPPORTED_OPERATION", "operation": operation}, sys.stdout, sort_keys=True) + return 2 + except Exception as error: # fail closed; caller receives a structured sealed-runtime RED + json.dump({"status": "ERROR", "operation": operation, "error": str(error)}, sys.stdout, sort_keys=True) + return 1 if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file diff --git a/runtime/learning-growth/python/pyproject.toml b/runtime/learning-growth/python/pyproject.toml index e192f84a2..e0c683fe1 100644 --- a/runtime/learning-growth/python/pyproject.toml +++ b/runtime/learning-growth/python/pyproject.toml @@ -10,8 +10,9 @@ dependencies = [ "opentelemetry-sdk==1.44.0", "opentelemetry-exporter-otlp-proto-http==1.44.0", "presidio-analyzer==2.2.364", - "presidio-anonymizer==2.2.364" + "presidio-anonymizer==2.2.364", + "vowpalwabbit==9.11.2" ] [tool.uv] -package = false +package = false \ No newline at end of file diff --git a/runtime/learning-growth/python/uv.lock b/runtime/learning-growth/python/uv.lock index 845df8d5a..cf2aa6e32 100644 --- a/runtime/learning-growth/python/uv.lock +++ b/runtime/learning-growth/python/uv.lock @@ -1460,6 +1460,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "vowpalwabbit" +version = "9.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/ff/bd0ffb16f5792a00151136741cfad876777777db7d14aae9a9b126d03c6f/vowpalwabbit-9.11.2.tar.gz", hash = "sha256:c0ecf8d773c0174a9ab7b0f9207a2608c894bc2fbec50380ca276e89553fd379", size = 35442893, upload-time = "2026-03-07T16:25:32.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/5b/f88c516cd411adb4636c9fa8df828c2e5338de3dc36accc68ea0143d4469/vowpalwabbit-9.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ee450a5dd40a60a9a12abfe16972410e8e5d06a7ee92d8c6ec15be4a7ec0ecb", size = 2283281, upload-time = "2026-03-07T16:25:07.933Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a1/c8d4ab89a2a3346645683ccc1b4e3c1fbbf68dc70d9901f896cfa03ac3f8/vowpalwabbit-9.11.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:fb962bce215757e4cd49d8d90341617416d5b360abdf476daa109b3eb4312825", size = 2479835, upload-time = "2026-03-07T16:25:09.984Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2b/bbc242bdd69d65daf130b65ae08046fa5ddac4effe85deb8dcd7de3cad7c/vowpalwabbit-9.11.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:690353cc881bbd26ac81e758ed4b7fe0fecae7edb534a1a86c26246e38c5aecd", size = 3010926, upload-time = "2026-03-07T16:25:11.727Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cf/62ae2a81385ca007a2b1db52a9a1d9a2a27afd2c183b14a8e522661295ed/vowpalwabbit-9.11.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62d117df1f5d2994130ee5b9f145d710c8198d065b061f0480ba0cab78aa3141", size = 3478813, upload-time = "2026-03-07T16:25:13.461Z" }, + { url = "https://files.pythonhosted.org/packages/06/3f/9231a026e3344118605a752933df5e935602efaa0281e50fe3d60361f3f3/vowpalwabbit-9.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdf3267faec119ce69dd147b11da234507cc1acd38a6ad61521188918e06d382", size = 1961008, upload-time = "2026-03-07T16:25:14.747Z" }, +] + [[package]] name = "wasabi" version = "1.1.3" @@ -1525,6 +1538,7 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "presidio-analyzer" }, { name = "presidio-anonymizer" }, + { name = "vowpalwabbit" }, ] [package.metadata] @@ -1537,6 +1551,7 @@ requires-dist = [ { name = "opentelemetry-sdk", specifier = "==1.44.0" }, { name = "presidio-analyzer", specifier = "==2.2.364" }, { name = "presidio-anonymizer", specifier = "==2.2.364" }, + { name = "vowpalwabbit", specifier = "==9.11.2" }, ] [[package]] diff --git a/third_party/licenses/vowpal-wabbit-BSD-3-Clause.txt b/third_party/licenses/vowpal-wabbit-BSD-3-Clause.txt new file mode 100644 index 000000000..5b46f6b80 --- /dev/null +++ b/third_party/licenses/vowpal-wabbit-BSD-3-Clause.txt @@ -0,0 +1,46 @@ +Copyright © Microsoft Corp 2012-2014, Yahoo! Inc. 2007-2012, and many +individual contributors. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without + +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + + notice, this list of conditions and the following disclaimer in the + + documentation and/or other materials provided with the distribution. + + * Neither the name of the Microsoft Corp nor the + + names of its contributors may be used to endorse or promote products + + derived from this software without specific prior written permission. + + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY + +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/tools/uat/v21LearningPolicyClosedLoopEvidence.js b/tools/uat/v21LearningPolicyClosedLoopEvidence.js new file mode 100644 index 000000000..dddeb23ee --- /dev/null +++ b/tools/uat/v21LearningPolicyClosedLoopEvidence.js @@ -0,0 +1,296 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const cp = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'yance-v21-policy-uat-')); +process.env.YANCE_DATA_DIR = root; +process.env.NODE_ENV = 'test'; + +const { R32SqliteStore } = require('../../backend/lib/r32SqliteStore'); +const { createPlatformCoreRepository } = require('../../backend/repositories/platformCoreRepository'); +const feedback = require('../../backend/services/replyFeedbackLearningService'); +const { createLearningPolicyDecisionContract } = require('../../backend/services/learningPolicyDecisionContract'); +const { createLearningOutcomeAttributionService } = require('../../backend/services/learningOutcomeAttributionService'); +const { createLearningDeepTrainingContract } = require('../../backend/services/learningDeepTrainingContract'); +const { createLearningPolicyRuntimeAdapter } = require('../../backend/services/learningPolicyRuntimeAdapter'); +const { createLearningPromotionAdapter } = require('../../backend/services/learningPromotionAdapter'); + +function runPolicyPython(payload) { + const pythonDir = path.resolve(__dirname, '../../runtime/learning-growth/python'); + const entrypoint = path.join(pythonDir, 'learning_entrypoint.py'); + const result = cp.spawnSync( + 'uv', + ['run', '--frozen', '--offline', 'python', entrypoint], + { cwd: pythonDir, input: JSON.stringify(payload), encoding: 'utf8' } + ); + if (result.status !== 0) { + throw new Error(`sealed policy runtime failed (${result.status}): ${result.stderr}\n${result.stdout}`); + } + return JSON.parse(result.stdout); +} + +async function main() { + const store = new R32SqliteStore({ dbPath: path.join(root, 'policy-uat.db') }); + const repository = createPlatformCoreRepository({ storeProvider: () => store }); + const identityAuthority = { + resolve({ contactId, conversationId }) { + return { + authority: 'PersonContextAuthority', + found: true, + personId: 'person-uat', + contactIds: [contactId], + conversationIds: [conversationId] + }; + } + }; + + const decisionContract = createLearningPolicyDecisionContract({ personContextAuthority: identityAuthority }); + const decision = decisionContract.createDecisionRecord({ + contactId: 'contact-uat', + conversationId: 'conversation-uat', + personaProfileId: 'owner', + featureBundle: { + interactionBand: 'balanced', + performanceMode: 'balanced', + questionPolicy: 'optional', + relationshipStage: 'trust_building', + targetLanguage: 'en' + }, + allowedActionSet: ['natural_hook', 'playful_attraction', 'direct_advance'], + candidateStrategyBranch: 'playful_attraction', + policyVersion: 'vw-p1-baseline-v1', + policyArtifactId: 'baseline', + generation: { + candidatePlanId: 'plan-uat', + directorStrategyId: 'strategy-uat', + contextVersion: 7, + conversationRevision: 3 + } + }); + assert.equal(decision.actionProbability, 1); + assert.equal(decision.exploration, false); + assert.equal(decision.rawPrivateChatPersisted, false); + + const sent = feedback.buildImmutableFeedbackSignal({ + eventType: 'sent', + evidenceId: 'outbox-uat', + outboxId: 'outbox-uat', + candidateId: 'candidate-uat', + contactId: 'contact-uat', + conversationId: 'conversation-uat', + observedAt: '2026-08-14T01:00:00.000Z', + learningMode: 'send_and_learn', + learningEligible: true, + personaTruthReceipt: { pass: true, receiptSha256: 'truth-uat' }, + generationMetadata: { + learningEligible: true, + personaTruthReceipt: { pass: true, receiptSha256: 'truth-uat' }, + decisionRecord: decision, + candidateStrategyBranchId: decision.candidateStrategyBranch + } + }); + assert.equal(sent.learningEligible, true); + assert.equal(sent.signal.decisionRecord.decisionId, decision.decisionId); + repository.insertLearningSignal(sent); + + const outcomeService = createLearningOutcomeAttributionService({ + repository, + personContextAuthority: identityAuthority + }); + const outcomeResult = outcomeService.persistInboundOutcome({ + id: 'inbound-uat-1', + externalMessageId: 'inbound-uat-1', + contactId: 'contact-uat', + conversationId: 'conversation-uat', + direction: 'inbound', + sentAt: '2026-08-14T01:02:00.000Z' + }); + assert.equal(outcomeResult.skipped, false); + assert.equal(outcomeResult.outcomeVector.learningEligible, false); + assert.equal(outcomeResult.outcomeVector.signals.replyLatencyMs.status, 'observed'); + assert.equal(outcomeResult.outcomeVector.signals.nextDayReinitiation.status, 'pending'); + + const rawRows = repository.listLearningSignals({ + scopeType: 'conversation', + scopeId: 'conversation-uat', + learningLevel: 'L1', + learningEligible: false + }); + const rawOutcome = rawRows.find(row => row.signal_type === 'policy_outcome_observed'); + assert.ok(rawOutcome); + assert.equal(Number(rawOutcome.learning_eligible), 0); + + const sourceRows = repository.listLearningSignals({ + scopeType: 'conversation', + scopeId: 'conversation-uat', + learningLevel: 'L1', + learningEligible: true + }); + const source = sourceRows.find(row => row.signal_type === 'candidate_sent'); + assert.ok(source); + const outcomeIds = rawOutcome.signal.outcomes.map(row => row.outcomeId).sort(); + const approvedScore = { + authority: 'Langfuse', + approvedByLearning: true, + scoreId: 'score-uat', + name: 'policy_reward', + value: 0.75, + traceId: 'trace-uat', + sourceSignalId: source.signal_id, + eligibleSourceSignalId: source.signal_id, + decisionId: decision.decisionId, + outcomeIds, + outcomeEvidenceSetRef: 'uat-evidence-set-v1', + rewardPolicyVersion: 'reward-policy-v1' + }; + + const deep = createLearningDeepTrainingContract({ + repository, + dataPolicy: { + async minimize() { return { allowed: true, text: '' }; } + } + }); + const projection = await deep.projectPolicy({ + scopeType: 'conversation', + scopeId: 'conversation-uat', + approvedScoresBySignalId: { [source.signal_id]: approvedScore }, + contentBySignalId: { [source.signal_id]: '' } + }); + assert.equal(projection.readOnly, true); + assert.equal(projection.trajectory.length, 1); + assert.equal(projection.trajectory[0].decision.decisionId, decision.decisionId); + assert.equal(projection.trajectory[0].vwTrainingEligible, true); + + const artifactPath = path.join(root, 'policy.vw'); + const trained = runPolicyPython({ + operation: 'policy_train', + rows: projection.trajectory, + artifactPath + }); + assert.match(trained.policyArtifactVersion, /^[a-f0-9]{64}$/u); + assert.equal(trained.exploration, false); + + const predicted = runPolicyPython({ + operation: 'policy_predict', + featureBundle: decision.featureBundle, + allowedActions: decision.allowedActionSet, + artifactPath, + policyArtifactId: trained.policyArtifactVersion, + policyVersion: 'vw-p1-uat-v1' + }); + assert.ok(decision.allowedActionSet.includes(predicted.action)); + assert.equal(predicted.probability, 1); + assert.equal(predicted.exploration, false); + + const runtimeAdapter = createLearningPolicyRuntimeAdapter({ + async invokeVowpalWabbit(input) { + return runPolicyPython({ + ...input, + artifactPath, + policyArtifactId: trained.policyArtifactVersion + }); + } + }); + const consumed = await runtimeAdapter.selectLearnedPolicyAction({ + featureBundle: decision.featureBundle, + allowedActions: decision.allowedActionSet, + baselineAction: 'natural_hook' + }); + assert.ok(decision.allowedActionSet.includes(consumed.candidateStrategyBranch)); + assert.equal(consumed.actionProbability, 1); + assert.equal(consumed.exploration, false); + + const degraded = await createLearningPolicyRuntimeAdapter({ + async invokeVowpalWabbit() { + const error = new Error('corrupt artifact'); + error.code = 'LEARNING_POLICY_ARTIFACT_IDENTITY_MISMATCH'; + throw error; + } + }).selectLearnedPolicyAction({ + featureBundle: decision.featureBundle, + allowedActions: decision.allowedActionSet, + baselineAction: 'natural_hook' + }); + assert.equal(degraded.executedPolicy, 'baseline'); + assert.equal(degraded.degradation.reasonCode, 'LEARNING_POLICY_ARTIFACT_IDENTITY_MISMATCH'); + + const promotion = createLearningPromotionAdapter({ + openFeature: { + setEvaluationContext() {} + }, + flagd: { + mode: 'in-process-offline' + } + }); + + const proposal = { + status: 'READY_FOR_REVIEW', + Candidate: { + id: `policy:${trained.policyArtifactVersion}`, + version: trained.policyArtifactVersion, + exposure: 0 + }, + Regression: { passed: true }, + Shadow: { passed: true } + }; + + const active = await promotion.promote(proposal, { + approved: true, + evidence: { id: 'uat-promotion-evidence' } + }); + + assert.equal(active.kind, 'LEARNING_ROLLOUT'); + assert.equal(active.automaticPromotion, false); + assert.equal(active.flagd, 'in-process-offline'); + + const rolledBack = await promotion.rollback(active, { + approved: true, + evidence: { id: 'uat-rollback-evidence' } + }); + + assert.equal(rolledBack.kind, 'LEARNING_ROLLBACK'); + assert.equal(rolledBack.automaticPromotion, false); + assert.equal(rolledBack.evidenceId, 'uat-rollback-evidence'); + + const brainSource = fs.readFileSync( + path.resolve(__dirname, '../../backend/services/contextAwareReplyBrain.js'), + 'utf8' + ); + const selectionIndex = brainSource.indexOf('selectLearnedPolicyAction('); + const finalFrontierIndex = brainSource.indexOf('let modelResult = await aiGateway.execute', selectionIndex); + assert.ok(selectionIndex >= 0 && finalFrontierIndex > selectionIndex); + + const receipt = { + workPackage: 'V21-LEARNING-POLICY-P1-DECISION-OUTCOME-CLOSED-LOOP-V2-SUCCESSOR', + canonicalIdentityBound: true, + storedHistoricalFeatureBundle: true, + behaviorPropensityLoggedAtDecision: true, + eligibleSourceSignalAnchored: true, + rawOutcomeEligibilityImmutableFalse: true, + outcomeWindowsAndMissingnessExplicit: true, + scoreEvidenceBindingVerified: true, + decisionOutcomeBound: true, + rawOutcomeIsNotReward: true, + providerPrivacyBoundaryProved: true, + learner: 'VowpalWabbit 9.11.2', + frontierGenerationAuthority: 'Model Brain / LiteLLM', + policyConsumedBeforeGeneration: true, + promotionAuthority: 'Learning', + availabilityFallbackProved: true, + rollbackProved: true, + localReplyModelUsed: false, + policyArtifactVersion: trained.policyArtifactVersion, + selectedAction: consumed.candidateStrategyBranch + }; + process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); + store.close?.(); +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); \ No newline at end of file From 9f191f709d87ec3d24fc91bcda2a3bd0ebdc855c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 07:17:58 +0700 Subject: [PATCH 03/21] feat(v21): seal Learned Policy Windows runtime Seal the exact Vowpal Wabbit/Python runtime from pinned OSS identities, extend the existing WP7 presealed-runtime seam to resources/learning-runtime before payload hashing, and prove native OpenFeature/flagd production-default promotion plus packaged consumption on Windows. --- .../v21-learning-policy-p1-windows.yml | 207 +++++++++++++++ .../learning-growth/build-windows-runtime.ps1 | 238 +++++++++++++++--- .../wp7/create-pre-review-trusted-product.js | 9 +- tools/wp7/lib.js | 85 ++++++- tools/wp7/packaged-product-trust.js | 4 +- 5 files changed, 501 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/v21-learning-policy-p1-windows.yml diff --git a/.github/workflows/v21-learning-policy-p1-windows.yml b/.github/workflows/v21-learning-policy-p1-windows.yml new file mode 100644 index 000000000..df9b976b9 --- /dev/null +++ b/.github/workflows/v21-learning-policy-p1-windows.yml @@ -0,0 +1,207 @@ +name: V2.1 Learned Policy P1 Windows Runtime + +on: + pull_request: + paths: + - '.github/workflows/v21-learning-policy-p1-windows.yml' + - 'backend/services/contextAwareReplyBrain.js' + - 'backend/services/learningDeepTrainingContract.js' + - 'backend/services/learningOutcomeAttributionService.js' + - 'backend/services/learningPolicyDecisionContract.js' + - 'backend/services/learningPolicyRuntimeAdapter.js' + - 'backend/services/learningPromotionAdapter.js' + - 'backend/services/replyFeedbackLearningService.js' + - 'backend/services/storeManagerService.js' + - 'config/upstreams/v21-learning-growth-brain-p0.json' + - 'runtime/learning-growth/python/**' + - 'tools/learning-growth/build-windows-runtime.ps1' + - 'tools/uat/v21LearningPolicyClosedLoopEvidence.js' + - 'tools/wp7/lib.js' + - 'tools/wp7/packaged-product-trust.js' + - 'tools/wp7/create-pre-review-trusted-product.js' + - 'THIRD_PARTY_NOTICES.md' + - 'third_party/licenses/vowpal-wabbit-BSD-3-Clause.txt' + - 'tests/wp0/v21-learning-policy-p1-*.test.js' + workflow_dispatch: + +permissions: + contents: read + +jobs: + seal-promote-and-consume: + runs-on: windows-latest + timeout-minutes: 45 + steps: + - name: Checkout exact candidate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + fetch-tags: false + lfs: false + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: '22' + package-manager-cache: false + + - name: Install locked Node production authority dependencies + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + npm.cmd ci --ignore-scripts --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { throw 'locked Node dependency installation failed' } + + - name: Seal Learned Policy Windows runtime from pinned OSS inputs + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + .\tools\learning-growth\build-windows-runtime.ps1 -OutputRoot "$env:RUNNER_TEMP\learning-runtime" + + - name: Verify sealed runtime excludes build resolver and VCS state + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $root = "$env:RUNNER_TEMP\learning-runtime" + foreach ($forbidden in @('uv.exe', '.git', 'uv.lock')) { + $found = @(Get-ChildItem -LiteralPath $root -Recurse -Force | Where-Object { $_.Name -eq $forbidden }) + if ($found.Count -ne 0) { throw "forbidden build-time artifact shipped: $forbidden" } + } + foreach ($required in @('venv\Scripts\python.exe','python\python.exe','learning_entrypoint.py','runtime-sbom.cdx.json','runtime-seal.json')) { + if (-not (Test-Path -LiteralPath (Join-Path $root $required) -PathType Leaf)) { throw "sealed Learning runtime missing: $required" } + } + $seal = Get-Content (Join-Path $root 'runtime-seal.json') -Raw | ConvertFrom-Json + if ($seal.documentType -ne 'YANCE_LEARNING_WINDOWS_RUNTIME_SEAL') { throw 'Learning runtime seal identity mismatch' } + if ($seal.learningPolicy.learner -ne 'Vowpal Wabbit' -or $seal.learningPolicy.version -ne '9.11.2') { throw 'Learning runtime Vowpal Wabbit identity mismatch' } + if ($seal.learningPolicy.exploration -ne $false -or $seal.learningPolicy.textGeneration -ne $false) { throw 'Learning runtime exceeded deterministic action-head boundary' } + if ($seal.runtime.dependencyResolution -ne 'build-time-only' -or $seal.runtime.networkResolutionAtRuntime -ne $false -or $seal.runtime.buildToolsShipped -ne $false) { throw 'Learning runtime seal violates offline packaging boundary' } + + - name: Train deterministic content-addressed VW candidate + shell: powershell + env: + HTTP_PROXY: 'http://127.0.0.1:9' + HTTPS_PROXY: 'http://127.0.0.1:9' + ALL_PROXY: 'http://127.0.0.1:9' + NO_PROXY: '127.0.0.1,localhost' + run: | + $ErrorActionPreference = 'Stop' + $root = "$env:RUNNER_TEMP\learning-runtime" + $python = Join-Path $root 'venv\Scripts\python.exe' + $entrypoint = Join-Path $root 'learning_entrypoint.py' + $artifact = Join-Path $env:RUNNER_TEMP 'learned-policy-candidate.vw' + $actions = @('natural_hook','playful_attraction','direct_advance','screen_and_advance','leave_aftertaste') + $rows = @( + @{ + featureBundle = @{ interactionBand = 'warm'; performanceMode = 'balanced'; questionPolicy = 'light'; relationshipStage = 'early'; targetLanguage = 'en' } + decision = @{ allowedActionSet = $actions; candidateStrategyBranch = 'natural_hook'; actionProbability = 1.0 } + approvedScore = @{ approvedByLearning = $true; value = 1.0 } + }, + @{ + featureBundle = @{ interactionBand = 'warm'; performanceMode = 'balanced'; questionPolicy = 'light'; relationshipStage = 'early'; targetLanguage = 'en' } + decision = @{ allowedActionSet = $actions; candidateStrategyBranch = 'natural_hook'; actionProbability = 1.0 } + approvedScore = @{ approvedByLearning = $true; value = 1.0 } + } + ) + $request = @{ operation = 'policy_train'; rows = $rows; artifactPath = $artifact } | ConvertTo-Json -Depth 12 -Compress + $output = $request | & $python -I $entrypoint + if ($LASTEXITCODE -ne 0) { throw "sealed VW training failed: $output" } + $result = $output | ConvertFrom-Json + if ($result.status -ne 'READY' -or $result.probability -ne 1 -or $result.exploration -ne $false) { throw "sealed VW training contract mismatch: $output" } + $digest = (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash.ToLowerInvariant() + if ($result.policyArtifactVersion -ne $digest -or $result.policyArtifactId -ne $digest) { throw 'trained VW artifact content identity mismatch' } + $dataRoot = Join-Path $env:RUNNER_TEMP 'learned-policy-production-data' + $candidateRoot = Join-Path $dataRoot 'learning\learned-policy\candidates' + New-Item -ItemType Directory -Force -Path $candidateRoot | Out-Null + Copy-Item -LiteralPath $artifact -Destination (Join-Path $candidateRoot "$digest.vw") + "YANCE_DATA_DIR=$dataRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "LEARNED_POLICY_DIGEST=$digest" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Production-default native promotion and packaged consumption closure + shell: powershell + env: + HTTP_PROXY: 'http://127.0.0.1:9' + HTTPS_PROXY: 'http://127.0.0.1:9' + ALL_PROXY: 'http://127.0.0.1:9' + NO_PROXY: '127.0.0.1,localhost' + run: | + $ErrorActionPreference = 'Stop' + $resources = Join-Path $env:RUNNER_TEMP 'learned-policy-product-resources' + New-Item -ItemType Directory -Force -Path $resources | Out-Null + @' + const fs = require('node:fs'); + const path = require('node:path'); + const assert = require('node:assert/strict'); + const { copyPresealedLearningRuntime, validatePresealedLearningRuntime } = require('./tools/wp7/lib'); + const { createLearningPromotionAdapter } = require('./backend/services/learningPromotionAdapter'); + const { createLearningPolicyRuntimeAdapter } = require('./backend/services/learningPolicyRuntimeAdapter'); + + (async () => { + const sourceRuntime = path.join(process.env.RUNNER_TEMP, 'learning-runtime'); + const resourcesRoot = path.join(process.env.RUNNER_TEMP, 'learned-policy-product-resources'); + const digest = process.env.LEARNED_POLICY_DIGEST; + const actions = ['natural_hook','playful_attraction','direct_advance','screen_and_advance','leave_aftertaste']; + const sealed = validatePresealedLearningRuntime(sourceRuntime); + assert.equal(sealed.seal.learningPolicy.learner, 'Vowpal Wabbit'); + assert.equal(sealed.seal.learningPolicy.version, '9.11.2'); + const copied = copyPresealedLearningRuntime(sourceRuntime, resourcesRoot); + assert.equal(copied.relativeRoot, 'resources/learning-runtime'); + assert.equal(copied.treeSha256, sealed.treeSha256); + Object.defineProperty(process, 'resourcesPath', { value: resourcesRoot, configurable: true }); + + const proposal = { + status: 'READY_FOR_REVIEW', + Regression: { passed: true }, + Shadow: { passed: true }, + Candidate: { id: `policy:${digest}`, version: digest, policyVersion: 'vw-p1-v1' } + }; + const promotion = createLearningPromotionAdapter(); + const rollout = await promotion.promote(proposal, { approved: true, evidence: { id: 'windows-production-closure' } }); + assert.equal(rollout.kind, 'LEARNING_ROLLOUT'); + assert.equal(rollout.candidate.version, digest); + assert.equal(rollout.OpenFeature, true); + assert.equal(rollout.flagd, 'in-process-offline'); + + const runtime = createLearningPolicyRuntimeAdapter(); + const featureBundle = { + interactionBand: 'warm', + performanceMode: 'balanced', + questionPolicy: 'light', + relationshipStage: 'early', + targetLanguage: 'en' + }; + const selected = await runtime.selectLearnedPolicyAction({ featureBundle, allowedActions: actions, baselineAction: 'natural_hook' }); + assert.equal(selected.executedPolicy, 'vowpalwabbit'); + assert.equal(selected.policyArtifactId, digest); + assert.equal(selected.actionProbability, 1); + assert.equal(selected.exploration, false); + assert.ok(actions.includes(selected.candidateStrategyBranch)); + + const promotedArtifact = path.join(process.env.YANCE_DATA_DIR, 'learning', 'learned-policy', 'artifacts', `${digest}.vw`); + fs.appendFileSync(promotedArtifact, Buffer.from('\ncorrupt-for-fail-safe-proof\n', 'utf8')); + const degraded = await runtime.selectLearnedPolicyAction({ featureBundle, allowedActions: actions, baselineAction: 'natural_hook' }); + assert.equal(degraded.executedPolicy, 'baseline'); + assert.equal(degraded.actionProbability, 1); + assert.equal(degraded.exploration, false); + assert.ok(degraded.degradation && degraded.degradation.reasonCode === 'LEARNING_POLICY_ARTIFACT_IDENTITY_MISMATCH'); + + process.stdout.write(JSON.stringify({ status: 'PASS', digest, selected, degraded, sealedTreeSha256: sealed.treeSha256 }) + '\n'); + })().catch((error) => { + console.error(error && error.stack || error); + process.exit(1); + }); + '@ | node + if ($LASTEXITCODE -ne 0) { throw 'production-default Learning promotion/runtime/WP7 closure failed' } + + - name: Re-run frozen Learned Policy P1 contracts + shell: powershell + run: | + node --test tests/wp0/v21-learning-policy-p1-decision-record.test.js tests/wp0/v21-learning-policy-p1-outcome-binding.test.js tests/wp0/v21-learning-policy-p1-projection.test.js tests/wp0/v21-learning-policy-p1-supply-chain.test.js tests/wp0/v21-learning-policy-p1-vw-runtime.test.js tests/wp0/v21-learning-policy-p1-production-consumption.test.js + if ($LASTEXITCODE -ne 0) { throw 'frozen Learned Policy P1 contracts failed' } + + - name: Upload sealed Learned Policy runtime + uses: actions/upload-artifact@v4 + with: + name: yance-learned-policy-windows-x64 + path: ${{ runner.temp }}/learning-runtime + if-no-files-found: error diff --git a/tools/learning-growth/build-windows-runtime.ps1 b/tools/learning-growth/build-windows-runtime.ps1 index 8e65ad4c1..2a170fe21 100644 --- a/tools/learning-growth/build-windows-runtime.ps1 +++ b/tools/learning-growth/build-windows-runtime.ps1 @@ -1,46 +1,212 @@ +[CmdletBinding()] param( - [Parameter(Mandatory = $true)][string]$PythonExe, - [Parameter(Mandatory = $true)][string]$UvExe, - [Parameter(Mandatory = $true)][string]$NodeExe, - [Parameter(Mandatory = $true)][string]$NpmCli, - [Parameter(Mandatory = $true)][string]$OutputRoot + [Parameter(Mandatory = $true)][string]$OutputRoot, + [string]$SourceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path, + [string]$WorkRoot = (Join-Path ([IO.Path]::GetTempPath()) ('yance-learning-policy-seal-' + [Guid]::NewGuid().ToString('N'))) ) +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path -$PythonRoot = Join-Path $RepoRoot 'runtime\learning-growth\python' -$PromptfooRoot = Join-Path $RepoRoot 'runtime\learning-growth\promptfoo' - -foreach ($required in @( - (Join-Path $PythonRoot 'pyproject.toml'), - (Join-Path $PythonRoot 'uv.lock'), - (Join-Path $PromptfooRoot 'package.json'), - (Join-Path $PromptfooRoot 'package-lock.json') -)) { - if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Missing sealed Learning lock input: $required" } +$ProgressPreference = 'SilentlyContinue' + +$UvVersion = '0.12.3' +$UvCommit = '507230998c9541d67814b57463ac00e454ff6991' +$UvAsset = 'uv-x86_64-pc-windows-msvc.zip' +$UvAssetSize = 19013455 +$UvAssetSha256 = 'b23350c79e8ad0192b8124af13a0f17e8d4e4549524785e1aef389ae5a06990e' +$PythonBuildStandaloneRelease = '20260807' +$PythonBuildStandaloneCommit = '00c8a06113f11220667c3bcf5fab1672ff9e78ef' +$PythonAsset = 'cpython-3.12.13+20260807-x86_64-pc-windows-msvc-install_only_stripped.tar.gz' +$PythonAssetSize = 21962247 +$PythonAssetSha256 = '18bcc65b17921806b72cdc88bcf000bf67a2c99a8fc381fe1629f2b9ba56858d' +$CpythonVersion = '3.12.13' +$VowpalWabbitVersion = '9.11.2' +$VowpalWabbitCommit = '122bae254a5b8bc2b774d13b33d53e6dbc2cfba7' +$LearningLockBlob = 'cf2aa6e320d6d5c16c92672136325f29ef4365ae' +$Utf8NoBom = New-Object Text.UTF8Encoding($false) + +function Assert-Sha256([string]$Path, [string]$Expected, [string]$Label) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "$Label is missing: $Path" } + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $Expected.ToLowerInvariant()) { throw "$Label SHA256 mismatch: expected=$Expected actual=$actual" } +} +function Assert-Size([string]$Path, [long]$Expected, [string]$Label) { + $actual = (Get-Item -LiteralPath $Path).Length + if ($actual -ne $Expected) { throw "$Label size mismatch: expected=$Expected actual=$actual" } +} +function Invoke-Checked([string]$FilePath, [string[]]$Arguments, [string]$Label) { + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit $LASTEXITCODE" } +} +function Relative-Path([string]$Root, [string]$Path) { + $rootFull = [IO.Path]::GetFullPath($Root) + $separator = [IO.Path]::DirectorySeparatorChar.ToString() + if (-not $rootFull.EndsWith($separator)) { $rootFull += $separator } + $rootUri = New-Object System.Uri($rootFull) + $pathUri = New-Object System.Uri([IO.Path]::GetFullPath($Path)) + return [Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace('\', '/') } +function Invoke-JsonEntrypoint([string]$PythonExe, [string]$Entrypoint, [hashtable]$Request, [string]$Label) { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $PythonExe + $psi.Arguments = '-I "' + $Entrypoint + '"' + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + if (-not $process.Start()) { throw "$Label failed to start" } + $process.StandardInput.Write(($Request | ConvertTo-Json -Depth 20 -Compress)) + $process.StandardInput.Close() + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + if ($process.ExitCode -ne 0) { throw "$Label failed with exit $($process.ExitCode): $stderr $stdout" } + try { return ($stdout | ConvertFrom-Json) } + catch { throw "$Label returned invalid JSON: $stdout" } +} + +$OutputRoot = [IO.Path]::GetFullPath($OutputRoot) +$SourceRoot = [IO.Path]::GetFullPath($SourceRoot) +$WorkRoot = [IO.Path]::GetFullPath($WorkRoot) +if (Test-Path -LiteralPath $OutputRoot) { + if ((Get-ChildItem -LiteralPath $OutputRoot -Force | Measure-Object).Count -gt 0) { throw "OutputRoot must be empty: $OutputRoot" } +} else { New-Item -ItemType Directory -Path $OutputRoot | Out-Null } +if (Test-Path -LiteralPath $WorkRoot) { throw "WorkRoot must not already exist: $WorkRoot" } +New-Item -ItemType Directory -Path $WorkRoot | Out-Null + +$DownloadRoot = Join-Path $WorkRoot 'downloads' +$ToolRoot = Join-Path $WorkRoot 'tools' +$ProjectRoot = Join-Path $SourceRoot 'runtime\learning-growth\python' +$PythonRoot = Join-Path $OutputRoot 'python' +$VenvRoot = Join-Path $OutputRoot 'venv' +$LicenseRoot = Join-Path $OutputRoot 'licenses' +New-Item -ItemType Directory -Path $DownloadRoot, $ToolRoot, $LicenseRoot | Out-Null +foreach ($required in @('pyproject.toml','uv.lock','learning_entrypoint.py')) { + if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot $required) -PathType Leaf)) { throw "Learning runtime source input missing: $required" } +} +$ActualLockBlob = (& git.exe -C $SourceRoot hash-object (Join-Path $ProjectRoot 'uv.lock')).Trim() +if ($ActualLockBlob -ne $LearningLockBlob) { throw "Learning uv.lock Git blob mismatch: expected=$LearningLockBlob actual=$ActualLockBlob" } -New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null -$PythonOut = Join-Path $OutputRoot 'python' -$PromptfooOut = Join-Path $OutputRoot 'promptfoo' -Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $PythonOut, $PromptfooOut -New-Item -ItemType Directory -Force -Path $PythonOut, $PromptfooOut | Out-Null +$UvZip = Join-Path $DownloadRoot $UvAsset +Invoke-WebRequest -UseBasicParsing -Uri "https://github.com/astral-sh/uv/releases/download/$UvVersion/$UvAsset" -OutFile $UvZip +Assert-Size $UvZip $UvAssetSize 'uv Windows x64 asset' +Assert-Sha256 $UvZip $UvAssetSha256 'uv Windows x64 asset' +Expand-Archive -LiteralPath $UvZip -DestinationPath $ToolRoot +$UvExe = (Get-ChildItem -LiteralPath $ToolRoot -Filter 'uv.exe' -Recurse | Select-Object -First 1).FullName +if (-not $UvExe) { throw 'uv.exe was not found in the verified asset' } -& $UvExe sync --project $PythonRoot --python $PythonExe --frozen --no-dev -if ($LASTEXITCODE -ne 0) { throw 'uv frozen Learning runtime build failed' } -Copy-Item -Recurse -Force (Join-Path $PythonRoot '.venv') (Join-Path $PythonOut '.venv') -Copy-Item -Force (Join-Path $PythonRoot 'learning_entrypoint.py') $PythonOut -Copy-Item -Force (Join-Path $PythonRoot 'uv.lock') $PythonOut -& $PythonExe (Join-Path $PythonRoot 'generate_runtime_sbom.py') (Join-Path $PythonOut 'sbom.json') -if ($LASTEXITCODE -ne 0) { throw 'Learning Python SBOM generation failed' } +$PythonTar = Join-Path $DownloadRoot $PythonAsset +$PythonUrl = "https://github.com/astral-sh/python-build-standalone/releases/download/$PythonBuildStandaloneRelease/$($PythonAsset.Replace('+','%2B'))" +Invoke-WebRequest -UseBasicParsing -Uri $PythonUrl -OutFile $PythonTar +Assert-Size $PythonTar $PythonAssetSize 'python-build-standalone CPython asset' +Assert-Sha256 $PythonTar $PythonAssetSha256 'python-build-standalone CPython asset' +Invoke-Checked 'tar.exe' @('-xf', $PythonTar, '-C', $OutputRoot) 'extract verified CPython asset' +$PythonExe = Join-Path $PythonRoot 'python.exe' +if (-not (Test-Path -LiteralPath $PythonExe -PathType Leaf)) { throw "standalone python.exe missing after extraction: $PythonExe" } +$PythonVersion = (& $PythonExe -I -c 'import platform; print(platform.python_version())').Trim() +if ($PythonVersion -ne $CpythonVersion) { throw "CPython version mismatch: expected=$CpythonVersion actual=$PythonVersion" } -Copy-Item -Recurse -Force $PromptfooRoot\* $PromptfooOut -Push-Location $PromptfooOut +$OldUvProjectEnvironment = $env:UV_PROJECT_ENVIRONMENT try { - & $NodeExe $NpmCli ci --ignore-scripts --no-audit --no-fund - if ($LASTEXITCODE -ne 0) { throw 'Promptfoo npm ci failed' } - & $NodeExe (Join-Path $PromptfooOut 'generate_runtime_sbom.js') (Join-Path $PromptfooOut 'sbom.json') - if ($LASTEXITCODE -ne 0) { throw 'Promptfoo SBOM generation failed' } -} finally { Pop-Location } + $env:UV_PROJECT_ENVIRONMENT = $VenvRoot + Invoke-Checked $UvExe @('sync', '--project', $ProjectRoot, '--frozen', '--no-dev', '--no-editable', '--python', $PythonExe) 'materialize Learning runtime from tracked uv.lock' +} finally { + if ($null -eq $OldUvProjectEnvironment) { Remove-Item Env:UV_PROJECT_ENVIRONMENT -ErrorAction SilentlyContinue } else { $env:UV_PROJECT_ENVIRONMENT = $OldUvProjectEnvironment } +} + +$VenvPython = Join-Path $VenvRoot 'Scripts\python.exe' +if (-not (Test-Path -LiteralPath $VenvPython -PathType Leaf)) { throw "materialized venv python missing: $VenvPython" } +$InstalledVwVersion = (& $VenvPython -I -c 'import importlib.metadata; print(importlib.metadata.version("vowpalwabbit"))').Trim() +if ($InstalledVwVersion -ne $VowpalWabbitVersion) { throw "installed Vowpal Wabbit version mismatch: expected=$VowpalWabbitVersion actual=$InstalledVwVersion" } +$Entrypoint = Join-Path $OutputRoot 'learning_entrypoint.py' +Copy-Item -LiteralPath (Join-Path $ProjectRoot 'learning_entrypoint.py') -Destination $Entrypoint + +foreach ($license in @( + 'third_party\licenses\vowpal-wabbit-BSD-3-Clause.txt', + 'third_party\licenses\uv-Apache-2.0.txt', + 'third_party\licenses\uv-MIT.txt', + 'third_party\licenses\python-build-standalone-MPL-2.0.txt', + 'third_party\licenses\cpython-PSF-2.0.txt' +)) { + $source = Join-Path $SourceRoot $license + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "runtime license input missing: $license" } + Copy-Item -LiteralPath $source -Destination $LicenseRoot +} +$AssetLicense = Join-Path $PythonRoot 'LICENSE.txt' +if (Test-Path -LiteralPath $AssetLicense -PathType Leaf) { Copy-Item -LiteralPath $AssetLicense -Destination (Join-Path $LicenseRoot 'cpython-asset-LICENSE.txt') } + +$contract = Invoke-JsonEntrypoint $VenvPython $Entrypoint @{ operation = 'policy_runtime_contract' } 'sealed Learned Policy runtime contract' +if ([string]$contract.status -ne 'READY' -or [string]$contract.authority -ne 'Vowpal Wabbit' -or [string]$contract.vowpalwabbit -ne $VowpalWabbitVersion -or [bool]$contract.exploration -ne $false -or [bool]$contract.textGeneration -ne $false) { + throw "sealed Learned Policy runtime contract mismatch: $($contract | ConvertTo-Json -Compress)" +} + +$SbomScript = Join-Path $WorkRoot 'generate_runtime_sbom.py' +$SbomScriptText = @' +import importlib.metadata as md +import json +import pathlib +import sys +components = [] +for dist in sorted(md.distributions(), key=lambda d: (d.metadata.get("Name") or "").lower()): + name = dist.metadata.get("Name") or "unknown" + version = dist.version + components.append({"type":"library","name":name,"version":version,"purl":f"pkg:pypi/{name.lower().replace('_','-')}@{version}"}) +doc = {"bomFormat":"CycloneDX","specVersion":"1.6","version":1,"metadata":{"component":{"type":"application","name":"yance-learning-policy-windows-runtime","version":"1"}},"components":components} +pathlib.Path(sys.argv[1]).write_text(json.dumps(doc, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8") +'@ +[IO.File]::WriteAllText($SbomScript, $SbomScriptText, $Utf8NoBom) +Invoke-Checked $VenvPython @('-I', $SbomScript, (Join-Path $OutputRoot 'runtime-sbom.cdx.json')) 'Learning runtime CycloneDX SBOM generation' + +# Build tools, resolver state, caches and VCS metadata are never part of OutputRoot. +Get-ChildItem -LiteralPath $OutputRoot -Directory -Recurse -Force | Where-Object { $_.Name -in @('__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache') } | Sort-Object FullName -Descending | Remove-Item -Recurse -Force +foreach ($forbiddenName in @('uv.exe', 'uv.lock', '.git')) { + $found = @(Get-ChildItem -LiteralPath $OutputRoot -Recurse -Force | Where-Object { $_.Name -eq $forbiddenName }) + if ($found.Count -ne 0) { throw "forbidden build-time artifact shipped: $forbiddenName" } +} + +$SealInput = Join-Path $WorkRoot 'runtime-tree.sha256-input.txt' +$CanonicalRecordsInput = Join-Path $WorkRoot 'runtime-tree.records.json' +$CanonicalRecordCountOutput = Join-Path $WorkRoot 'runtime-tree.record-count.txt' +$Wp1Lib = Join-Path $SourceRoot 'tools\wp1\lib.js' +if (-not (Test-Path -LiteralPath $Wp1Lib -PathType Leaf)) { throw "WP1 canonicalization authority missing: $Wp1Lib" } +$NodeExe = (Get-Command 'node.exe' -ErrorAction Stop).Source +$records = @() +Get-ChildItem -LiteralPath $OutputRoot -File -Recurse -Force | Where-Object { $_.Name -ne 'runtime-seal.json' } | ForEach-Object { + $rel = Relative-Path $OutputRoot $_.FullName + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $records += [ordered]@{ path = $rel; sizeBytes = [int64]$_.Length; sha256 = $hash } +} +$recordsJson = ConvertTo-Json -InputObject @($records) -Depth 4 -Compress +[IO.File]::WriteAllText($CanonicalRecordsInput, ($recordsJson + "`n"), $Utf8NoBom) +$CanonicalizeScript = @' +const fs = require('node:fs'); +const { canonicalizePayloadRecords } = require(process.argv[1]); +const records = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const canonical = canonicalizePayloadRecords(records); +const lines = canonical.map((row) => `${row.path}|${row.sizeBytes}|${row.sha256}`); +fs.writeFileSync(process.argv[3], `${lines.join('\n')}\n`, 'utf8'); +fs.writeFileSync(process.argv[4], `${canonical.length}\n`, 'utf8'); +'@ +Invoke-Checked $NodeExe @('-e', $CanonicalizeScript, $Wp1Lib, $CanonicalRecordsInput, $SealInput, $CanonicalRecordCountOutput) 'canonicalize Learning runtime records through WP1 authority' +[int]$CanonicalRecordCount = (Get-Content -LiteralPath $CanonicalRecordCountOutput -Raw).Trim() +if ($CanonicalRecordCount -ne $records.Count) { throw "WP1 canonicalization record count mismatch: input=$($records.Count) canonical=$CanonicalRecordCount" } +$TreeSha = (Get-FileHash -LiteralPath $SealInput -Algorithm SHA256).Hash.ToLowerInvariant() +$SbomSha = (Get-FileHash -LiteralPath (Join-Path $OutputRoot 'runtime-sbom.cdx.json') -Algorithm SHA256).Hash.ToLowerInvariant() +$seal = [ordered]@{ + schemaVersion = 1 + documentType = 'YANCE_LEARNING_WINDOWS_RUNTIME_SEAL' + learningPolicy = [ordered]@{ learner = 'Vowpal Wabbit'; version = $VowpalWabbitVersion; commit = $VowpalWabbitCommit; mode = 'contextual-bandit-adf-offline-candidate-policy'; exploration = $false; textGeneration = $false } + python = [ordered]@{ version = $CpythonVersion; buildStandaloneRelease = $PythonBuildStandaloneRelease; buildStandaloneCommit = $PythonBuildStandaloneCommit; asset = $PythonAsset; assetSizeBytes = $PythonAssetSize; assetSha256 = $PythonAssetSha256 } + uv = [ordered]@{ version = $UvVersion; commit = $UvCommit; asset = $UvAsset; assetSizeBytes = $UvAssetSize; assetSha256 = $UvAssetSha256; sourceLockGitBlob = $LearningLockBlob } + runtime = [ordered]@{ fileCount = $CanonicalRecordCount; treeSha256 = $TreeSha; sbomSha256 = $SbomSha; dependencyResolution = 'build-time-only'; networkResolutionAtRuntime = $false; buildToolsShipped = $false } +} +$sealJson = ConvertTo-Json -InputObject $seal -Depth 8 +[IO.File]::WriteAllText((Join-Path $OutputRoot 'runtime-seal.json'), ($sealJson + "`n"), $Utf8NoBom) -Write-Output "Learning sealed Windows runtime prepared at $OutputRoot" +Write-Host "SEALED_LEARNING_RUNTIME=$OutputRoot" +Write-Host "TREE_SHA256=$TreeSha" +Write-Host "SBOM_SHA256=$SbomSha" +Write-Host "FILE_COUNT=$CanonicalRecordCount" diff --git a/tools/wp7/create-pre-review-trusted-product.js b/tools/wp7/create-pre-review-trusted-product.js index 071b02407..a84b564a7 100644 --- a/tools/wp7/create-pre-review-trusted-product.js +++ b/tools/wp7/create-pre-review-trusted-product.js @@ -28,6 +28,7 @@ const ENV_BY_ARGUMENT = Object.freeze({ '--production-node-modules': 'WP7_PRODUCTION_NODE_MODULES', '--trusted-node-executable': 'WP7_TRUSTED_NODE_EXECUTABLE', '--parlant-runtime': 'WP7_PARLANT_RUNTIME_ROOT', + '--learning-runtime': 'WP7_LEARNING_RUNTIME_ROOT', '--rcedit-path': 'WP7_RCEDIT_PATH', '--archive-tool-node-modules': 'WP7_ARCHIVE_TOOL_NODE_MODULES', '--platform-auth-config': 'WP7_PLATFORM_AUTH_CONFIG_PATH', @@ -108,6 +109,7 @@ function resolveBuildInputs(options = {}) { productionNodeModulesSource: assertDirectory(argumentValue('--production-node-modules', options), 'WP7_PRODUCTION_DEPENDENCY_DIRECTORY_TREE_MISMATCH', 'reviewed production node_modules'), trustedNodeExecutable: assertRegular(argumentValue('--trusted-node-executable', options), 'WP7_NODE_RUNTIME_EXECUTABLE_MISSING', 'trusted Node executable'), parlantRuntimeSource: assertDirectory(argumentValue('--parlant-runtime', options), 'WP7_PARLANT_RUNTIME_REQUIRED', 'presealed Parlant runtime'), + learningRuntimeSource: assertDirectory(argumentValue('--learning-runtime', options), 'WP7_LEARNING_RUNTIME_REQUIRED', 'presealed Learning runtime'), rceditPath: assertRegular(argumentValue('--rcedit-path', options), 'WP7_RCEDIT_EXECUTABLE_REQUIRED', 'trusted rcedit executable'), archiveToolNodeModules: assertDirectory(argumentValue('--archive-tool-node-modules', options), 'WP7_PRE_REVIEW_TRUSTED_PRODUCT_ARCHIVE_FAILED', 'isolated archive OSS node_modules'), platformAuthConfigPath: argumentValue('--platform-auth-config', options) ? assertRegular(argumentValue('--platform-auth-config', options), 'WP7_PLATFORM_AUTH_RELEASE_CONFIG_MISSING', 'sealed platform auth configuration') : null, @@ -134,7 +136,7 @@ function run(options = {}) { const inputs = resolveBuildInputs(options); const { repoRoot, outputRoot, electronArchivePath, electronDist, electronNpmPackageRoot, productionNodeModulesSource, - trustedNodeExecutable, parlantRuntimeSource, rceditPath, archiveToolNodeModules, platformAuthConfigPath, platformAuthHashPath, requirePlatformAuth, + trustedNodeExecutable, parlantRuntimeSource, learningRuntimeSource, rceditPath, archiveToolNodeModules, platformAuthConfigPath, platformAuthHashPath, requirePlatformAuth, buildTimestampUtc, buildSessionId, targetPlatform, targetArch, allowNonWindowsReviewFixture } = inputs; if (!/^[0-9a-f]{16,64}$/.test(buildSessionId)) fail('WP7_PRE_REVIEW_BUILD_SESSION_ID_INVALID', 'build session ID must be 16-64 lowercase hexadecimal characters', { buildSessionId }); @@ -154,6 +156,7 @@ function run(options = {}) { electronDist, trustedNodeExecutable, parlantRuntimeSource, + learningRuntimeSource, electronArchivePath, rceditPath, platformAuthConfigPath, @@ -241,6 +244,10 @@ function run(options = {}) { parlantRuntimeSealSha256: built.runtime.parlantRuntime.sealSha256, parlantRuntimeTreeSha256: built.runtime.parlantRuntime.treeSha256, parlantRuntimeFileCount: built.runtime.parlantRuntime.fileCount, + learningRuntimeRelativePath: 'application-payload/resources/learning-runtime', + learningRuntimeSealSha256: built.runtime.learningRuntime.sealSha256, + learningRuntimeTreeSha256: built.runtime.learningRuntime.treeSha256, + learningRuntimeFileCount: built.runtime.learningRuntime.fileCount, nativeBinaryScanSha256: closure.nativeBinaryScanSha256, nativeBinaryFileCount: closure.nativeBinaryScan.fileCount, nativeBinaryFailureCount: closure.nativeBinaryScan.failureCount, diff --git a/tools/wp7/lib.js b/tools/wp7/lib.js index bc3fa11c9..ce110ab8d 100644 --- a/tools/wp7/lib.js +++ b/tools/wp7/lib.js @@ -421,6 +421,77 @@ function copyPresealedParlantRuntime(sourceRoot, resourcesRoot) { } return Object.freeze({ ...copied, relativeRoot: 'resources/parlant-runtime' }); } +function presealedLearningRuntimeRecords(runtimeRoot) { + const root = path.resolve(runtimeRoot); + if (!fs.existsSync(root)) throw new Wp7Error('WP7_LEARNING_RUNTIME_REQUIRED', 'presealed Learning runtime input is missing', { runtimeRoot: root }); + const rootStat = fs.lstatSync(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Wp7Error('WP7_LEARNING_RUNTIME_INVALID', 'presealed Learning runtime must be a real non-symlink directory', { runtimeRoot: root }); + const records = []; + function visit(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new Wp7Error('WP7_LEARNING_RUNTIME_SYMLINK_REJECTED', 'symlinks are forbidden in the presealed Learning runtime', { path: absolute }); + if (entry.isDirectory()) visit(absolute); + else if (entry.isFile()) { + if (entry.name === 'runtime-seal.json') continue; + const relative = path.relative(root, absolute).split(path.sep).join('/'); + const stat = fs.statSync(absolute); + records.push(Object.freeze({ path: relative, sizeBytes: stat.size, sha256: sha256File(absolute) })); + } else throw new Wp7Error('WP7_LEARNING_RUNTIME_INVALID', 'unsupported file type in presealed Learning runtime', { path: absolute }); + } + } + visit(root); + return Object.freeze(wp1.canonicalizePayloadRecords(records)); +} +function validatePresealedLearningRuntime(runtimeRoot) { + const root = path.resolve(runtimeRoot); + const required = [ + 'runtime-seal.json', + 'runtime-sbom.cdx.json', + 'learning_entrypoint.py', + 'python/python.exe', + 'venv/Scripts/python.exe' + ]; + for (const relative of required) { + const absolute = path.join(root, ...relative.split('/')); + if (!fs.existsSync(absolute)) throw new Wp7Error('WP7_LEARNING_RUNTIME_INVALID', 'presealed Learning runtime is missing a required file', { relative }); + const stat = fs.lstatSync(absolute); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Wp7Error('WP7_LEARNING_RUNTIME_INVALID', 'presealed Learning runtime required path must be a regular non-symlink file', { relative }); + } + let seal; + try { seal = JSON.parse(fs.readFileSync(path.join(root, 'runtime-seal.json'), 'utf8')); } + catch (error) { throw new Wp7Error('WP7_LEARNING_RUNTIME_SEAL_INVALID', 'presealed Learning runtime seal is not valid JSON', { message: error.message }); } + if (seal.schemaVersion !== 1 || seal.documentType !== 'YANCE_LEARNING_WINDOWS_RUNTIME_SEAL') throw new Wp7Error('WP7_LEARNING_RUNTIME_SEAL_INVALID', 'presealed Learning runtime seal identity is invalid'); + if (seal.learningPolicy?.learner !== 'Vowpal Wabbit' || seal.learningPolicy?.version !== '9.11.2' || seal.learningPolicy?.commit !== '122bae254a5b8bc2b774d13b33d53e6dbc2cfba7' || seal.learningPolicy?.exploration !== false || seal.learningPolicy?.textGeneration !== false) { + throw new Wp7Error('WP7_LEARNING_RUNTIME_SEAL_INVALID', 'presealed Learning runtime policy identity is invalid'); + } + if (!seal.runtime || !Number.isInteger(seal.runtime.fileCount) || seal.runtime.fileCount <= 0 || !SHA256_RE.test(String(seal.runtime.treeSha256 || '')) || !SHA256_RE.test(String(seal.runtime.sbomSha256 || ''))) { + throw new Wp7Error('WP7_LEARNING_RUNTIME_SEAL_INVALID', 'presealed Learning runtime seal runtime identity is invalid'); + } + if (seal.runtime.dependencyResolution !== 'build-time-only' || seal.runtime.networkResolutionAtRuntime !== false || seal.runtime.buildToolsShipped !== false) { + throw new Wp7Error('WP7_LEARNING_RUNTIME_SEAL_INVALID', 'presealed Learning runtime must prohibit runtime dependency resolution/network and exclude build tools'); + } + const records = presealedLearningRuntimeRecords(root); + const canonical = Buffer.from(records.map((row) => `${row.path}|${row.sizeBytes}|${row.sha256}\n`).join(''), 'utf8'); + const treeSha256 = sha256Buffer(canonical); + if (records.length !== seal.runtime.fileCount || treeSha256 !== seal.runtime.treeSha256) { + throw new Wp7Error('WP7_LEARNING_RUNTIME_TREE_MISMATCH', 'presealed Learning runtime tree does not match its runtime seal', { expectedFileCount: seal.runtime.fileCount, actualFileCount: records.length, expectedTreeSha256: seal.runtime.treeSha256, actualTreeSha256: treeSha256 }); + } + const sbomSha256 = sha256File(path.join(root, 'runtime-sbom.cdx.json')); + if (sbomSha256 !== seal.runtime.sbomSha256) throw new Wp7Error('WP7_LEARNING_RUNTIME_SBOM_MISMATCH', 'presealed Learning runtime SBOM does not match its runtime seal', { expected: seal.runtime.sbomSha256, actual: sbomSha256 }); + return Object.freeze({ root: fs.realpathSync(root), fileCount: records.length, treeSha256, sbomSha256, sealSha256: sha256File(path.join(root, 'runtime-seal.json')), seal }); +} +function copyPresealedLearningRuntime(sourceRoot, resourcesRoot) { + const source = validatePresealedLearningRuntime(sourceRoot); + const destinationRoot = path.join(path.resolve(resourcesRoot), 'learning-runtime'); + if (fs.existsSync(destinationRoot)) throw new Wp7Error('WP7_LEARNING_RUNTIME_DESTINATION_NOT_EMPTY', 'Learning runtime destination must not already exist', { destinationRoot }); + copyTree(source.root, destinationRoot, { missingReason: 'WP7_LEARNING_RUNTIME_REQUIRED' }); + const copied = validatePresealedLearningRuntime(destinationRoot); + if (copied.fileCount !== source.fileCount || copied.treeSha256 !== source.treeSha256 || copied.sbomSha256 !== source.sbomSha256 || copied.sealSha256 !== source.sealSha256) { + throw new Wp7Error('WP7_LEARNING_RUNTIME_COPY_MISMATCH', 'copied Learning runtime differs from the presealed source', { source, copied }); + } + return Object.freeze({ ...copied, relativeRoot: 'resources/learning-runtime' }); +} function copyProductionDependencyTree(sourceRoot, destinationRoot) { const excludedGeneratedBinDirectories = []; const sourceBase = path.resolve(sourceRoot); @@ -591,6 +662,9 @@ function assembleWindowsApplication(options = {}) { const parlantRuntime = options.parlantRuntimeSource ? copyPresealedParlantRuntime(options.parlantRuntimeSource, path.join(payloadRoot, 'resources')) : null; + const learningRuntime = options.learningRuntimeSource + ? copyPresealedLearningRuntime(options.learningRuntimeSource, path.join(payloadRoot, 'resources')) + : null; return { status: 'PASS', payloadRoot, @@ -600,6 +674,7 @@ function assembleWindowsApplication(options = {}) { targetArch, nodeRuntime, parlantRuntime, + learningRuntime, productionDependencyCanonicalization }; } @@ -631,7 +706,8 @@ function buildFinalWindowsPayload(options = {}) { targetPlatform, targetArch, trustedNodeExecutable: options.trustedNodeExecutable, - parlantRuntimeSource: options.parlantRuntimeSource + parlantRuntimeSource: options.parlantRuntimeSource, + learningRuntimeSource: options.learningRuntimeSource }); const sourceClosure = validateReviewedApplicationSourceClosure(payloadRoot, repoRoot, identity.sourceCommit, { platform: targetPlatform }); const dependencies = verifyProductionDependencyClosure({ repoRoot, appRoot: runtime.appRoot, sourceCommit: identity.sourceCommit, platform: targetPlatform, arch: targetArch }); @@ -1279,7 +1355,8 @@ function buildAuthorizedFinalWindowsInstaller(options = {}) { platformAuthConfigPath: options.platformAuthConfigPath, platformAuthHashPath: options.platformAuthHashPath, requirePlatformAuth: options.requirePlatformAuth === true, - parlantRuntimeSource: options.parlantRuntimeSource + parlantRuntimeSource: options.parlantRuntimeSource, + learningRuntimeSource: options.learningRuntimeSource }); if (typeof options.afterPayloadHook === 'function') options.afterPayloadHook({ repoRoot, frozenRoot: frozen.frozenRoot, stagingRoot, identity, built }); assertSourceStillFrozen(repoRoot, identity, frozen.frozenRoot, frozenContent); @@ -1481,7 +1558,9 @@ module.exports = { createDetachedFrozenSource, assertSourceStillFrozen, trackedWorkingTreeSha256, completeProjectSourceTreeSha256, readReleaseSource, verifyRuntimeProtocolConvergence, ensureDirectoryEmpty, acquireExclusiveLease, assertCanonicalPayloadPath, assertNoWp1Reuse, buildSessionId, - writePreReviewInstallerFixture, readPreReviewInstallerFixture, copyTree, presealedParlantRuntimeRecords, validatePresealedParlantRuntime, copyPresealedParlantRuntime, copyProductionDependencyTree, installReleasePlatformAuth, assembleWindowsApplication, buildFinalWindowsPayload, buildManifestAndPayload, buildPreReviewFixture, + writePreReviewInstallerFixture, readPreReviewInstallerFixture, copyTree, presealedParlantRuntimeRecords, validatePresealedParlantRuntime, copyPresealedParlantRuntime, + presealedLearningRuntimeRecords, validatePresealedLearningRuntime, copyPresealedLearningRuntime, + copyProductionDependencyTree, installReleasePlatformAuth, assembleWindowsApplication, buildFinalWindowsPayload, buildManifestAndPayload, buildPreReviewFixture, assertSessionSealed, validateBuildIdentity, validateRiskRegister, validateDeferredScope, validateEvidenceReferences, validateEvidenceCommon, validateCrossFileIdentity, validateCleanInstallEvidence, validateBootFailureDiagnostics, validateAcceptanceMapping, validatePhaseModel, verifyRequiredTestImplementations, validateWorkstreamTraceability, validateAllGovernance, diff --git a/tools/wp7/packaged-product-trust.js b/tools/wp7/packaged-product-trust.js index 1615b5037..03983cde5 100644 --- a/tools/wp7/packaged-product-trust.js +++ b/tools/wp7/packaged-product-trust.js @@ -244,7 +244,7 @@ function compareElectronDistributionTree(options = {}) { 'resources/platform-auth.sha256', 'resources/evidence/native-binary-scan.json' ]); - const allowedAddition = (relative) => relative.startsWith('resources/app/') || relative.startsWith('resources/runtime/node22/') || relative.startsWith('resources/parlant-runtime/') || metadata.has(relative); + const allowedAddition = (relative) => relative.startsWith('resources/app/') || relative.startsWith('resources/runtime/node22/') || relative.startsWith('resources/parlant-runtime/') || relative.startsWith('resources/learning-runtime/') || metadata.has(relative); const missing = official.filter((row) => !actual.has(row.payloadPath)).map((row) => row.payloadPath); const mismatched = official.filter((row) => { if (!actual.has(row.payloadPath)) return false; @@ -275,7 +275,7 @@ function compareElectronDistributionTree(options = {}) { if (missing.length || mismatched.length || modeMismatched.length || extra.length) fail('WP7_ELECTRON_DISTRIBUTION_TREE_TRUST_NOT_ENFORCED', 'packaged Electron runtime tree content and unixMode are not an exact projection of the trusted release archive plus explicit product additions', { missing, mismatched, modeMismatched, extra }); const distributionTreeSha256 = sha256Buffer(Buffer.from(official.map((row) => `${row.payloadPath}\0${row.sizeBytes}\0${row.sha256}\0${Number(row.unixMode || 0).toString(8).padStart(6, '0')}\n`).join(''), 'utf8')); if (options.expectedDistributionTreeSha256 && options.expectedDistributionTreeSha256 !== distributionTreeSha256) fail('WP7_ELECTRON_DISTRIBUTION_TREE_IDENTITY_MISMATCH', 'Electron distribution tree hash including unixMode differs from the bound release identity', { expected: options.expectedDistributionTreeSha256, actual: distributionTreeSha256 }); - return Object.freeze({ archiveFileCount: official.length, modeBoundFileCount: official.filter((row) => Number(row.unixMode || 0) !== 0).length, distributionTreeSha256, records: official.map(({ payloadPath, ...row }) => ({ ...row, payloadPath })), allowedProductAdditions: ['resources/app/**', 'resources/runtime/node22/**', 'resources/parlant-runtime/**', ...metadata] }); + return Object.freeze({ archiveFileCount: official.length, modeBoundFileCount: official.filter((row) => Number(row.unixMode || 0) !== 0).length, distributionTreeSha256, records: official.map(({ payloadPath, ...row }) => ({ ...row, payloadPath })), allowedProductAdditions: ['resources/app/**', 'resources/runtime/node22/**', 'resources/parlant-runtime/**', 'resources/learning-runtime/**', ...metadata] }); } function verifyElectronDistributionTree(options = {}) { From ea0bf76cb863c28795203b52f79ffdd50a8e7cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:00:54 +0700 Subject: [PATCH 04/21] fix(v21): repair Learned Policy Windows argv boundary Mirror the trusted Parlant sealed-runtime argv pattern so PowerShell cannot strip the Vowpal Wabbit package-name quotes before Python parses the metadata probe. Yance-Causal-Red-Run: 31853191013 Yance-Causal-Red-Job: 94932840005 Yance-Causal-Red-Root-Cause: PowerShell native argv stripped embedded Python package-name quotes --- tools/learning-growth/build-windows-runtime.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/learning-growth/build-windows-runtime.ps1 b/tools/learning-growth/build-windows-runtime.ps1 index 2a170fe21..83923d339 100644 --- a/tools/learning-growth/build-windows-runtime.ps1 +++ b/tools/learning-growth/build-windows-runtime.ps1 @@ -119,7 +119,7 @@ try { $VenvPython = Join-Path $VenvRoot 'Scripts\python.exe' if (-not (Test-Path -LiteralPath $VenvPython -PathType Leaf)) { throw "materialized venv python missing: $VenvPython" } -$InstalledVwVersion = (& $VenvPython -I -c 'import importlib.metadata; print(importlib.metadata.version("vowpalwabbit"))').Trim() +$InstalledVwVersion = (& $VenvPython -I -c 'import importlib.metadata, sys; print(importlib.metadata.version(sys.argv[1]))' 'vowpalwabbit').Trim() if ($InstalledVwVersion -ne $VowpalWabbitVersion) { throw "installed Vowpal Wabbit version mismatch: expected=$VowpalWabbitVersion actual=$InstalledVwVersion" } $Entrypoint = Join-Path $OutputRoot 'learning_entrypoint.py' Copy-Item -LiteralPath (Join-Path $ProjectRoot 'learning_entrypoint.py') -Destination $Entrypoint From 0d3ca73167fda0550f50f3cbd8f27ac4a5829d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:06:25 +0700 Subject: [PATCH 05/21] fix(v21): use native stdin for sealed policy contract Remove the custom ProcessStartInfo stdin bridge and feed the existing JSON-over-stdin Learning runtime contract through the repository-native Windows PowerShell native pipeline while preserving explicit exit and JSON validation. Yance-Causal-Red-Run: 31855341808 Yance-Causal-Red-Job: 94938934405 Yance-Causal-Red-Root-Cause: custom ProcessStartInfo bridge delivered EOF instead of the policy_runtime_contract JSON request --- .../learning-growth/build-windows-runtime.ps1 | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/tools/learning-growth/build-windows-runtime.ps1 b/tools/learning-growth/build-windows-runtime.ps1 index 83923d339..7af82f6dd 100644 --- a/tools/learning-growth/build-windows-runtime.ps1 +++ b/tools/learning-growth/build-windows-runtime.ps1 @@ -47,23 +47,11 @@ function Relative-Path([string]$Root, [string]$Path) { return [Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace('\', '/') } function Invoke-JsonEntrypoint([string]$PythonExe, [string]$Entrypoint, [hashtable]$Request, [string]$Label) { - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = $PythonExe - $psi.Arguments = '-I "' + $Entrypoint + '"' - $psi.UseShellExecute = $false - $psi.RedirectStandardInput = $true - $psi.RedirectStandardOutput = $true - $psi.RedirectStandardError = $true - $psi.CreateNoWindow = $true - $process = New-Object System.Diagnostics.Process - $process.StartInfo = $psi - if (-not $process.Start()) { throw "$Label failed to start" } - $process.StandardInput.Write(($Request | ConvertTo-Json -Depth 20 -Compress)) - $process.StandardInput.Close() - $stdout = $process.StandardOutput.ReadToEnd() - $stderr = $process.StandardError.ReadToEnd() - $process.WaitForExit() - if ($process.ExitCode -ne 0) { throw "$Label failed with exit $($process.ExitCode): $stderr $stdout" } + $requestJson = $Request | ConvertTo-Json -Depth 20 -Compress + $stdoutLines = @($requestJson | & $PythonExe -I $Entrypoint) + $exitCode = $LASTEXITCODE + $stdout = $stdoutLines -join "`n" + if ($exitCode -ne 0) { throw "$Label failed with exit $exitCode: $stdout" } try { return ($stdout | ConvertFrom-Json) } catch { throw "$Label returned invalid JSON: $stdout" } } From 96b67e669450edfef1a51f583dc6f1e916b184d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:09:40 +0700 Subject: [PATCH 06/21] fix(v21): delimit Learned Policy Windows exit code Use an explicit PowerShell subexpression at the native stdin helper error boundary so Windows PowerShell 5.1 cannot parse the following colon as drive-qualified variable syntax. Yance-Causal-Red-Run: 31855612609 Yance-Causal-Red-Job: 94939694755 Yance-Causal-Red-Root-Cause: Windows PowerShell 5.1 parsed $exitCode: as an invalid drive-qualified variable reference --- tools/learning-growth/build-windows-runtime.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/learning-growth/build-windows-runtime.ps1 b/tools/learning-growth/build-windows-runtime.ps1 index 7af82f6dd..72862d28a 100644 --- a/tools/learning-growth/build-windows-runtime.ps1 +++ b/tools/learning-growth/build-windows-runtime.ps1 @@ -51,7 +51,7 @@ function Invoke-JsonEntrypoint([string]$PythonExe, [string]$Entrypoint, [hashtab $stdoutLines = @($requestJson | & $PythonExe -I $Entrypoint) $exitCode = $LASTEXITCODE $stdout = $stdoutLines -join "`n" - if ($exitCode -ne 0) { throw "$Label failed with exit $exitCode: $stdout" } + if ($exitCode -ne 0) { throw "$Label failed with exit $($exitCode): $stdout" } try { return ($stdout | ConvertFrom-Json) } catch { throw "$Label returned invalid JSON: $stdout" } } From 486c8b5da976b60bfe740e5120c88e0dcb9a61df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:13:05 +0700 Subject: [PATCH 07/21] fix(v21): use file-backed stdin for sealed policy contract Preserve the existing JSON-over-stdin Learning runtime protocol while replacing the Windows PowerShell native pipeline with built-in Start-Process standard-stream file redirection so CPython receives the exact request bytes on Windows PowerShell 5.1. Yance-Causal-Red-Run: 31855765465 Yance-Causal-Red-Job: 94940129676 Yance-Causal-Red-Root-Cause: Windows PowerShell native pipeline delivered EOF instead of the policy_runtime_contract JSON request --- .../learning-growth/build-windows-runtime.ps1 | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tools/learning-growth/build-windows-runtime.ps1 b/tools/learning-growth/build-windows-runtime.ps1 index 72862d28a..5e35d0e95 100644 --- a/tools/learning-growth/build-windows-runtime.ps1 +++ b/tools/learning-growth/build-windows-runtime.ps1 @@ -47,13 +47,22 @@ function Relative-Path([string]$Root, [string]$Path) { return [Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace('\', '/') } function Invoke-JsonEntrypoint([string]$PythonExe, [string]$Entrypoint, [hashtable]$Request, [string]$Label) { - $requestJson = $Request | ConvertTo-Json -Depth 20 -Compress - $stdoutLines = @($requestJson | & $PythonExe -I $Entrypoint) - $exitCode = $LASTEXITCODE - $stdout = $stdoutLines -join "`n" - if ($exitCode -ne 0) { throw "$Label failed with exit $($exitCode): $stdout" } - try { return ($stdout | ConvertFrom-Json) } - catch { throw "$Label returned invalid JSON: $stdout" } + $requestPath = [IO.Path]::GetTempFileName() + $stdoutPath = [IO.Path]::GetTempFileName() + $stderrPath = [IO.Path]::GetTempFileName() + try { + $requestJson = $Request | ConvertTo-Json -Depth 20 -Compress + [IO.File]::WriteAllText($requestPath, ($requestJson + "`n"), $Utf8NoBom) + $argumentList = @('-I', ('"' + $Entrypoint + '"')) + $process = Start-Process -FilePath $PythonExe -ArgumentList $argumentList -RedirectStandardInput $requestPath -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -NoNewWindow -Wait -PassThru + $stdout = [IO.File]::ReadAllText($stdoutPath) + $stderr = [IO.File]::ReadAllText($stderrPath) + if ($process.ExitCode -ne 0) { throw "$Label failed with exit $($process.ExitCode): $stderr $stdout" } + try { return ($stdout | ConvertFrom-Json) } + catch { throw "$Label returned invalid JSON: $stdout" } + } finally { + Remove-Item -LiteralPath $requestPath, $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } } $OutputRoot = [IO.Path]::GetFullPath($OutputRoot) From 6af68ac400f1b93a3966dad0f718424f208f5ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:18:10 +0700 Subject: [PATCH 08/21] fix(v21): use file-backed stdin for sealed VW training Reuse the Windows PowerShell 5.1 file-backed standard-stream pattern already proven by the sealed runtime contract so the deterministic policy_train request reaches CPython without weakening the JSON-over-stdin protocol or the offline runtime boundary. Yance-Causal-Red-Run: 31855922058 Yance-Causal-Red-Job: 94940560662 Yance-Causal-Red-Root-Cause: Windows PowerShell native pipeline delivered EOF instead of the policy_train JSON request --- .../workflows/v21-learning-policy-p1-windows.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/v21-learning-policy-p1-windows.yml b/.github/workflows/v21-learning-policy-p1-windows.yml index df9b976b9..e400ccc07 100644 --- a/.github/workflows/v21-learning-policy-p1-windows.yml +++ b/.github/workflows/v21-learning-policy-p1-windows.yml @@ -104,8 +104,20 @@ jobs: } ) $request = @{ operation = 'policy_train'; rows = $rows; artifactPath = $artifact } | ConvertTo-Json -Depth 12 -Compress - $output = $request | & $python -I $entrypoint - if ($LASTEXITCODE -ne 0) { throw "sealed VW training failed: $output" } + $requestPath = [IO.Path]::GetTempFileName() + $stdoutPath = [IO.Path]::GetTempFileName() + $stderrPath = [IO.Path]::GetTempFileName() + try { + $utf8NoBom = New-Object Text.UTF8Encoding($false) + [IO.File]::WriteAllText($requestPath, ($request + "`n"), $utf8NoBom) + $argumentList = @('-I', ('"' + $entrypoint + '"')) + $process = Start-Process -FilePath $python -ArgumentList $argumentList -RedirectStandardInput $requestPath -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -NoNewWindow -Wait -PassThru + $output = [IO.File]::ReadAllText($stdoutPath) + $stderr = [IO.File]::ReadAllText($stderrPath) + if ($process.ExitCode -ne 0) { throw "sealed VW training failed with exit $($process.ExitCode): $stderr $output" } + } finally { + Remove-Item -LiteralPath $requestPath, $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } $result = $output | ConvertFrom-Json if ($result.status -ne 'READY' -or $result.probability -ne 1 -or $result.exploration -ne $false) { throw "sealed VW training contract mismatch: $output" } $digest = (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash.ToLowerInvariant() From 54264f20f6102ff9651a19ed68288229f4985212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:27:28 +0700 Subject: [PATCH 09/21] fix(v21): run production closure as desktop host Use the repository-native explicit desktop-host process identity before loading the WP7 production dependency graph so the production-default Learning promotion/runtime closure exercises the same host role contract as the real authority-host process matrix without weakening storage guards. Yance-Causal-Red-Run: 31856167539 Yance-Causal-Red-Job: 94941241951 Yance-Causal-Red-Root-Cause: production-host closure probe loaded WP7 storage dependency chain without the explicit desktop-host process role --- .github/workflows/v21-learning-policy-p1-windows.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/v21-learning-policy-p1-windows.yml b/.github/workflows/v21-learning-policy-p1-windows.yml index e400ccc07..7c7320e94 100644 --- a/.github/workflows/v21-learning-policy-p1-windows.yml +++ b/.github/workflows/v21-learning-policy-p1-windows.yml @@ -144,6 +144,7 @@ jobs: const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); + process.env.YANCE_PROCESS_ROLE = 'desktop-host'; const { copyPresealedLearningRuntime, validatePresealedLearningRuntime } = require('./tools/wp7/lib'); const { createLearningPromotionAdapter } = require('./backend/services/learningPromotionAdapter'); const { createLearningPolicyRuntimeAdapter } = require('./backend/services/learningPolicyRuntimeAdapter'); From 7064bf0cfe50380fa4cbbe2a8dd4f7cb0a0447e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:33:37 +0700 Subject: [PATCH 10/21] fix(v21): keep sealed Learning runtime immutable Prevent Python bytecode writes after the Learning runtime tree seal is created by using Python's native -B mode for both the deterministic post-seal VW training probe and real packaged production prediction. This preserves the sealed payload instead of weakening WP7 tree verification. Yance-Causal-Red-Run: 31856584290 Yance-Causal-Red-Job: 94942385281 Yance-Causal-Red-Root-Cause: post-seal Python execution wrote bytecode into the sealed Learning runtime tree before WP7 revalidation --- .github/workflows/v21-learning-policy-p1-windows.yml | 2 +- backend/services/learningPolicyRuntimeAdapter.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/v21-learning-policy-p1-windows.yml b/.github/workflows/v21-learning-policy-p1-windows.yml index 7c7320e94..5be2459dc 100644 --- a/.github/workflows/v21-learning-policy-p1-windows.yml +++ b/.github/workflows/v21-learning-policy-p1-windows.yml @@ -110,7 +110,7 @@ jobs: try { $utf8NoBom = New-Object Text.UTF8Encoding($false) [IO.File]::WriteAllText($requestPath, ($request + "`n"), $utf8NoBom) - $argumentList = @('-I', ('"' + $entrypoint + '"')) + $argumentList = @('-B', '-I', ('"' + $entrypoint + '"')) $process = Start-Process -FilePath $python -ArgumentList $argumentList -RedirectStandardInput $requestPath -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -NoNewWindow -Wait -PassThru $output = [IO.File]::ReadAllText($stdoutPath) $stderr = [IO.File]::ReadAllText($stderrPath) diff --git a/backend/services/learningPolicyRuntimeAdapter.js b/backend/services/learningPolicyRuntimeAdapter.js index 66ba70e7e..d9891703d 100644 --- a/backend/services/learningPolicyRuntimeAdapter.js +++ b/backend/services/learningPolicyRuntimeAdapter.js @@ -133,7 +133,7 @@ function invokeProductionVowpalWabbit(input = {}) { policyArtifactId: input.policyArtifactId, policyVersion: input.policyVersion || POLICY_VERSION }; - const result = spawnSync(runtime.python, ['-I', runtime.entrypoint], { + const result = spawnSync(runtime.python, ['-B', '-I', runtime.entrypoint], { input: JSON.stringify(request), encoding: 'utf8', windowsHide: true, From fd4e90f2b91d044f0b91e7fe2f455fccb09bf84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:46:48 +0700 Subject: [PATCH 11/21] fix(v21): harden Learned Policy production authority Close independent-review production blockers without changing frozen tests: fail safe on the active artifact instead of silently consuming history, move sealed VW prediction off the synchronous reply path, serialize native rollout read-modify-write with the already-locked proper-lockfile OSS dependency, reject stale rollback receipts and malformed flag documents, reject mixed-validity training rows, and close OpenFeature provider lifecycles in verification/short-lived closure paths. Yance-Independent-Review: CodeRabbit PR #384 Yance-Review-Findings: active-artifact-fallback,reply-path-spawnSync,rollout-rmw-race,stale-rollback,invalid-training-row,flagd-provider-lifecycle --- .../v21-learning-policy-p1-windows.yml | 103 ++++++------- .../services/learningPolicyRuntimeAdapter.js | 73 ++++++---- backend/services/learningPromotionAdapter.js | 137 ++++++++++++------ .../python/learning_entrypoint.py | 4 +- 4 files changed, 198 insertions(+), 119 deletions(-) diff --git a/.github/workflows/v21-learning-policy-p1-windows.yml b/.github/workflows/v21-learning-policy-p1-windows.yml index 5be2459dc..6af0ca87e 100644 --- a/.github/workflows/v21-learning-policy-p1-windows.yml +++ b/.github/workflows/v21-learning-policy-p1-windows.yml @@ -144,61 +144,66 @@ jobs: const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); + const { OpenFeature } = require('@openfeature/server-sdk'); process.env.YANCE_PROCESS_ROLE = 'desktop-host'; const { copyPresealedLearningRuntime, validatePresealedLearningRuntime } = require('./tools/wp7/lib'); const { createLearningPromotionAdapter } = require('./backend/services/learningPromotionAdapter'); const { createLearningPolicyRuntimeAdapter } = require('./backend/services/learningPolicyRuntimeAdapter'); (async () => { - const sourceRuntime = path.join(process.env.RUNNER_TEMP, 'learning-runtime'); - const resourcesRoot = path.join(process.env.RUNNER_TEMP, 'learned-policy-product-resources'); - const digest = process.env.LEARNED_POLICY_DIGEST; - const actions = ['natural_hook','playful_attraction','direct_advance','screen_and_advance','leave_aftertaste']; - const sealed = validatePresealedLearningRuntime(sourceRuntime); - assert.equal(sealed.seal.learningPolicy.learner, 'Vowpal Wabbit'); - assert.equal(sealed.seal.learningPolicy.version, '9.11.2'); - const copied = copyPresealedLearningRuntime(sourceRuntime, resourcesRoot); - assert.equal(copied.relativeRoot, 'resources/learning-runtime'); - assert.equal(copied.treeSha256, sealed.treeSha256); - Object.defineProperty(process, 'resourcesPath', { value: resourcesRoot, configurable: true }); - - const proposal = { - status: 'READY_FOR_REVIEW', - Regression: { passed: true }, - Shadow: { passed: true }, - Candidate: { id: `policy:${digest}`, version: digest, policyVersion: 'vw-p1-v1' } - }; - const promotion = createLearningPromotionAdapter(); - const rollout = await promotion.promote(proposal, { approved: true, evidence: { id: 'windows-production-closure' } }); - assert.equal(rollout.kind, 'LEARNING_ROLLOUT'); - assert.equal(rollout.candidate.version, digest); - assert.equal(rollout.OpenFeature, true); - assert.equal(rollout.flagd, 'in-process-offline'); - - const runtime = createLearningPolicyRuntimeAdapter(); - const featureBundle = { - interactionBand: 'warm', - performanceMode: 'balanced', - questionPolicy: 'light', - relationshipStage: 'early', - targetLanguage: 'en' - }; - const selected = await runtime.selectLearnedPolicyAction({ featureBundle, allowedActions: actions, baselineAction: 'natural_hook' }); - assert.equal(selected.executedPolicy, 'vowpalwabbit'); - assert.equal(selected.policyArtifactId, digest); - assert.equal(selected.actionProbability, 1); - assert.equal(selected.exploration, false); - assert.ok(actions.includes(selected.candidateStrategyBranch)); - - const promotedArtifact = path.join(process.env.YANCE_DATA_DIR, 'learning', 'learned-policy', 'artifacts', `${digest}.vw`); - fs.appendFileSync(promotedArtifact, Buffer.from('\ncorrupt-for-fail-safe-proof\n', 'utf8')); - const degraded = await runtime.selectLearnedPolicyAction({ featureBundle, allowedActions: actions, baselineAction: 'natural_hook' }); - assert.equal(degraded.executedPolicy, 'baseline'); - assert.equal(degraded.actionProbability, 1); - assert.equal(degraded.exploration, false); - assert.ok(degraded.degradation && degraded.degradation.reasonCode === 'LEARNING_POLICY_ARTIFACT_IDENTITY_MISMATCH'); - - process.stdout.write(JSON.stringify({ status: 'PASS', digest, selected, degraded, sealedTreeSha256: sealed.treeSha256 }) + '\n'); + try { + const sourceRuntime = path.join(process.env.RUNNER_TEMP, 'learning-runtime'); + const resourcesRoot = path.join(process.env.RUNNER_TEMP, 'learned-policy-product-resources'); + const digest = process.env.LEARNED_POLICY_DIGEST; + const actions = ['natural_hook','playful_attraction','direct_advance','screen_and_advance','leave_aftertaste']; + const sealed = validatePresealedLearningRuntime(sourceRuntime); + assert.equal(sealed.seal.learningPolicy.learner, 'Vowpal Wabbit'); + assert.equal(sealed.seal.learningPolicy.version, '9.11.2'); + const copied = copyPresealedLearningRuntime(sourceRuntime, resourcesRoot); + assert.equal(copied.relativeRoot, 'resources/learning-runtime'); + assert.equal(copied.treeSha256, sealed.treeSha256); + Object.defineProperty(process, 'resourcesPath', { value: resourcesRoot, configurable: true }); + + const proposal = { + status: 'READY_FOR_REVIEW', + Regression: { passed: true }, + Shadow: { passed: true }, + Candidate: { id: `policy:${digest}`, version: digest, policyVersion: 'vw-p1-v1' } + }; + const promotion = createLearningPromotionAdapter(); + const rollout = await promotion.promote(proposal, { approved: true, evidence: { id: 'windows-production-closure' } }); + assert.equal(rollout.kind, 'LEARNING_ROLLOUT'); + assert.equal(rollout.candidate.version, digest); + assert.equal(rollout.OpenFeature, true); + assert.equal(rollout.flagd, 'in-process-offline'); + + const runtime = createLearningPolicyRuntimeAdapter(); + const featureBundle = { + interactionBand: 'warm', + performanceMode: 'balanced', + questionPolicy: 'light', + relationshipStage: 'early', + targetLanguage: 'en' + }; + const selected = await runtime.selectLearnedPolicyAction({ featureBundle, allowedActions: actions, baselineAction: 'natural_hook' }); + assert.equal(selected.executedPolicy, 'vowpalwabbit'); + assert.equal(selected.policyArtifactId, digest); + assert.equal(selected.actionProbability, 1); + assert.equal(selected.exploration, false); + assert.ok(actions.includes(selected.candidateStrategyBranch)); + + const promotedArtifact = path.join(process.env.YANCE_DATA_DIR, 'learning', 'learned-policy', 'artifacts', `${digest}.vw`); + fs.appendFileSync(promotedArtifact, Buffer.from('\ncorrupt-for-fail-safe-proof\n', 'utf8')); + const degraded = await runtime.selectLearnedPolicyAction({ featureBundle, allowedActions: actions, baselineAction: 'natural_hook' }); + assert.equal(degraded.executedPolicy, 'baseline'); + assert.equal(degraded.actionProbability, 1); + assert.equal(degraded.exploration, false); + assert.ok(degraded.degradation && degraded.degradation.reasonCode === 'LEARNING_POLICY_ARTIFACT_IDENTITY_MISMATCH'); + + process.stdout.write(JSON.stringify({ status: 'PASS', digest, selected, degraded, sealedTreeSha256: sealed.treeSha256 }) + '\n'); + } finally { + await OpenFeature.close(); + } })().catch((error) => { console.error(error && error.stack || error); process.exit(1); diff --git a/backend/services/learningPolicyRuntimeAdapter.js b/backend/services/learningPolicyRuntimeAdapter.js index d9891703d..cfa27a13f 100644 --- a/backend/services/learningPolicyRuntimeAdapter.js +++ b/backend/services/learningPolicyRuntimeAdapter.js @@ -3,7 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); -const { spawnSync } = require('node:child_process'); +const { spawn } = require('node:child_process'); const { ALLOWED_ACTIONS, normalizeFeatureBundle } = require('./learningPolicyDecisionContract'); const AUTHORITY = 'LearningPolicyRuntimeAdapter'; @@ -98,17 +98,10 @@ async function resolveProductionActivePolicy() { const client = await flagdClientFor(roots.flagFile); const rollout = await client.getObjectValue(ACTIVE_FLAG_KEY, null); if (!rollout || rollout.kind !== 'LEARNING_ROLLOUT') return null; - const candidates = [rollout.candidate, ...(Array.isArray(rollout.history) ? rollout.history : [])]; - let firstError = null; - for (const candidate of candidates) { - try { - return validatePolicyCandidate(candidate, roots); - } catch (error) { - firstError ||= error; - } - } - if (firstError) throw firstError; - return null; + // Runtime consumption is fail-safe against the active rollout only. History + // is rollback evidence/authority and must never silently substitute a broken + // active artifact without an explicit rollback receipt. + return validatePolicyCandidate(rollout.candidate, roots); } function sealedLearningRuntimePaths() { @@ -133,20 +126,50 @@ function invokeProductionVowpalWabbit(input = {}) { policyArtifactId: input.policyArtifactId, policyVersion: input.policyVersion || POLICY_VERSION }; - const result = spawnSync(runtime.python, ['-B', '-I', runtime.entrypoint], { - input: JSON.stringify(request), - encoding: 'utf8', - windowsHide: true, - timeout: 15000, - env: { ...process.env, HTTP_PROXY: 'http://127.0.0.1:9', HTTPS_PROXY: 'http://127.0.0.1:9', ALL_PROXY: 'http://127.0.0.1:9', NO_PROXY: '127.0.0.1,localhost' } + return new Promise((resolve, reject) => { + let child; + try { + child = spawn(runtime.python, ['-B', '-I', runtime.entrypoint], { + windowsHide: true, + timeout: 15000, + env: { ...process.env, HTTP_PROXY: 'http://127.0.0.1:9', HTTPS_PROXY: 'http://127.0.0.1:9', ALL_PROXY: 'http://127.0.0.1:9', NO_PROXY: '127.0.0.1,localhost' }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + } catch (error) { + reject(runtimeError('SEALED_VW_POLICY_PREDICTION_FAILED', clean(error?.message) || 'Failed to start sealed VW runtime.')); + return; + } + let stdout = ''; + let stderr = ''; + let stdinError = null; + let settled = false; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.stdin.on('error', error => { stdinError = error; }); + child.once('error', error => { + if (settled) return; + settled = true; + reject(runtimeError('SEALED_VW_POLICY_PREDICTION_FAILED', clean(error?.message) || 'Failed to start sealed VW runtime.')); + }); + child.once('close', (status, signal) => { + if (settled) return; + let parsed = null; + try { parsed = JSON.parse(clean(stdout) || '{}'); } catch (_) {} + if (status !== 0 || parsed?.status === 'ERROR' || stdinError) { + settled = true; + reject(runtimeError( + 'SEALED_VW_POLICY_PREDICTION_FAILED', + clean(parsed?.error || stderr || stdinError?.message || `sealed VW runtime exit ${status}${signal ? ` signal ${signal}` : ''}`) + )); + return; + } + settled = true; + resolve(parsed); + }); + child.stdin.end(JSON.stringify(request)); }); - if (result.error) throw result.error; - let parsed = null; - try { parsed = JSON.parse(clean(result.stdout) || '{}'); } catch (_) {} - if (result.status !== 0 || parsed?.status === 'ERROR') { - throw runtimeError('SEALED_VW_POLICY_PREDICTION_FAILED', clean(parsed?.error || result.stderr || `sealed VW runtime exit ${result.status}`)); - } - return parsed; } function createLearningPolicyRuntimeAdapter(options = {}) { diff --git a/backend/services/learningPromotionAdapter.js b/backend/services/learningPromotionAdapter.js index b54c62f01..b19786336 100644 --- a/backend/services/learningPromotionAdapter.js +++ b/backend/services/learningPromotionAdapter.js @@ -3,6 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); +const lockfile = require('proper-lockfile'); const ACTIVE_FLAG_KEY = 'yance-learning-policy-active'; const SHA256_RE = /^[0-9a-f]{64}$/u; @@ -53,9 +54,42 @@ function nativeFlagDocument(rollout) { } function readNativeRollout(flagFile) { if (!fs.existsSync(flagFile)) return null; - const doc = JSON.parse(fs.readFileSync(flagFile, 'utf8')); + let doc; + try { + doc = JSON.parse(fs.readFileSync(flagFile, 'utf8')); + } catch (error) { + throw promotionError('LEARNING_PROMOTION_NATIVE_FLAG_DOCUMENT_INVALID', 'Native Learning flag document is not valid JSON.', { message: clean(error?.message) }); + } return doc?.flags?.[ACTIVE_FLAG_KEY]?.variants?.active || null; } +async function withNativeRolloutLock(roots, operation) { + fs.mkdirSync(roots.flagRoot, { recursive: true }); + let release; + try { + release = await lockfile.lock(roots.flagRoot, { + realpath: true, + retries: { retries: 20, factor: 1.2, minTimeout: 25, maxTimeout: 100 } + }); + } catch (error) { + throw promotionError('LEARNING_PROMOTION_LOCK_UNAVAILABLE', 'Native Learning rollout lock could not be acquired.', { causeCode: clean(error?.code) }); + } + let value; + let operationError = null; + try { + value = await operation(); + } catch (error) { + operationError = error; + } + try { + await release(); + } catch (error) { + if (!operationError) { + throw promotionError('LEARNING_PROMOTION_LOCK_RELEASE_FAILED', 'Native Learning rollout lock could not be released.', { causeCode: clean(error?.code) }); + } + } + if (operationError) throw operationError; + return value; +} function materializeCandidate(roots, candidate) { fs.mkdirSync(roots.artifactRoot, { recursive: true }); const source = path.join(roots.candidateRoot, `${candidate.version}.vw`); @@ -84,18 +118,22 @@ function materializeCandidate(roots, candidate) { return destination; } async function verifyNativeFlagd(flagFile, expectedVersion) { - const { OpenFeature } = require('@openfeature/server-sdk'); + const { OpenFeature, NOOP_PROVIDER } = require('@openfeature/server-sdk'); const { FlagdProvider } = require('@openfeature/flagd-provider'); const domain = 'yance-learning-policy-promotion'; - await OpenFeature.setProviderAndWait(domain, new FlagdProvider({ - resolverType: 'in-process', - offlineFlagSourcePath: flagFile - })); - const evaluated = await OpenFeature.getClient(domain).getObjectValue(ACTIVE_FLAG_KEY, null); - if (!evaluated || clean(evaluated?.candidate?.version) !== expectedVersion) { - throw promotionError('LEARNING_PROMOTION_FLAGD_VERIFICATION_FAILED', 'Native flagd activation did not resolve the promoted candidate.'); + const provider = new FlagdProvider({ resolverType: 'in-process', offlineFlagSourcePath: flagFile }); + await OpenFeature.setProviderAndWait(domain, provider); + try { + const evaluated = await OpenFeature.getClient(domain).getObjectValue(ACTIVE_FLAG_KEY, null); + if (!evaluated || clean(evaluated?.candidate?.version) !== expectedVersion) { + throw promotionError('LEARNING_PROMOTION_FLAGD_VERIFICATION_FAILED', 'Native flagd activation did not resolve the promoted candidate.'); + } + return evaluated; + } finally { + // Replace only this verification domain. The SDK lifecycle closes the + // FlagdProvider watcher without shutting down unrelated OpenFeature domains. + await OpenFeature.setProviderAndWait(domain, NOOP_PROVIDER); } - return evaluated; } function createLearningPromotionAdapter(options = {}) { @@ -134,24 +172,27 @@ function createLearningPromotionAdapter(options = {}) { } const roots = canonicalRoots(); - materializeCandidate(roots, candidate); - const previous = readNativeRollout(roots.flagFile); - const previousCandidates = [previous?.candidate, ...(Array.isArray(previous?.history) ? previous.history : [])] - .filter(row => row && clean(row.version) && clean(row.version) !== candidate.version) - .slice(0, 8) - .map(row => ({ id: clean(row.id), version: clean(row.version), policyVersion: clean(row.policyVersion) || 'vw-p1-v1' })); - const rollout = Object.freeze({ - kind: 'LEARNING_ROLLOUT', - candidate, - history: Object.freeze(previousCandidates), - evidenceId, - approvedAt: new Date().toISOString(), - OpenFeature: true, - flagd: 'in-process-offline', - automaticPromotion: false + const rollout = await withNativeRolloutLock(roots, async () => { + materializeCandidate(roots, candidate); + const previous = readNativeRollout(roots.flagFile); + const previousCandidates = [previous?.candidate, ...(Array.isArray(previous?.history) ? previous.history : [])] + .filter(row => row && clean(row.version) && clean(row.version) !== candidate.version) + .slice(0, 8) + .map(row => ({ id: clean(row.id), version: clean(row.version), policyVersion: clean(row.policyVersion) || 'vw-p1-v1' })); + const next = Object.freeze({ + kind: 'LEARNING_ROLLOUT', + candidate, + history: Object.freeze(previousCandidates), + evidenceId, + approvedAt: new Date().toISOString(), + OpenFeature: true, + flagd: 'in-process-offline', + automaticPromotion: false + }); + atomicWriteJson(roots.flagFile, nativeFlagDocument(next)); + await verifyNativeFlagd(roots.flagFile, candidate.version); + return next; }); - atomicWriteJson(roots.flagFile, nativeFlagDocument(rollout)); - await verifyNativeFlagd(roots.flagFile, candidate.version); await langfuse?.recordPromotion?.({ proposal, rollout }); return rollout; } @@ -175,26 +216,34 @@ function createLearningPromotionAdapter(options = {}) { return receipt; } + const requestedCandidate = candidateIdentity(rollout.candidate || {}); const roots = canonicalRoots(); - const canonical = readNativeRollout(roots.flagFile); - const history = Array.isArray(canonical?.history) ? canonical.history : []; - const previous = history[0] ? candidateIdentity(history[0]) : null; - if (previous) { - const previousPath = path.join(roots.artifactRoot, `${previous.version}.vw`); - if (!fs.existsSync(previousPath) || sha256File(previousPath) !== previous.version) { - throw promotionError('LEARNING_ROLLBACK_LAST_KNOWN_GOOD_INVALID', 'Rollback target is not a verified content-addressed promoted artifact.'); + const restoredCandidate = await withNativeRolloutLock(roots, async () => { + const canonical = readNativeRollout(roots.flagFile); + const activeVersion = clean(canonical?.candidate?.version); + if (!activeVersion || activeVersion !== requestedCandidate.version) { + throw promotionError('LEARNING_ROLLBACK_STALE_ROLLOUT', 'Rollback receipt does not identify the currently active Learning rollout.', { requestedVersion: requestedCandidate.version, activeVersion }); } - const next = Object.freeze({ - kind: 'LEARNING_ROLLOUT', candidate: previous, history: Object.freeze(history.slice(1)), - evidenceId, approvedAt: new Date().toISOString(), OpenFeature: true, flagd: 'in-process-offline', automaticPromotion: false - }); - atomicWriteJson(roots.flagFile, nativeFlagDocument(next)); - await verifyNativeFlagd(roots.flagFile, previous.version); - } else { - atomicWriteJson(roots.flagFile, { flags: {} }); - } + const history = Array.isArray(canonical?.history) ? canonical.history : []; + const previous = history[0] ? candidateIdentity(history[0]) : null; + if (previous) { + const previousPath = path.join(roots.artifactRoot, `${previous.version}.vw`); + if (!fs.existsSync(previousPath) || sha256File(previousPath) !== previous.version) { + throw promotionError('LEARNING_ROLLBACK_LAST_KNOWN_GOOD_INVALID', 'Rollback target is not a verified content-addressed promoted artifact.'); + } + const next = Object.freeze({ + kind: 'LEARNING_ROLLOUT', candidate: previous, history: Object.freeze(history.slice(1)), + evidenceId, approvedAt: new Date().toISOString(), OpenFeature: true, flagd: 'in-process-offline', automaticPromotion: false + }); + atomicWriteJson(roots.flagFile, nativeFlagDocument(next)); + await verifyNativeFlagd(roots.flagFile, previous.version); + } else { + atomicWriteJson(roots.flagFile, { flags: {} }); + } + return previous; + }); const receipt = Object.freeze({ - kind: 'LEARNING_ROLLBACK', rollout, candidate: rollout.candidate, restoredCandidate: previous, + kind: 'LEARNING_ROLLBACK', rollout, candidate: rollout.candidate, restoredCandidate, evidenceId, rolledBackAt: new Date().toISOString(), OpenFeature: true, flagd: 'in-process-offline', automaticPromotion: false }); await langfuse?.recordRollback?.({ rollout, evidence: input.evidence, receipt }); diff --git a/runtime/learning-growth/python/learning_entrypoint.py b/runtime/learning-growth/python/learning_entrypoint.py index a479d3aad..bc7b616fa 100644 --- a/runtime/learning-growth/python/learning_entrypoint.py +++ b/runtime/learning-growth/python/learning_entrypoint.py @@ -154,7 +154,9 @@ def _policy_rows(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]: rows = payload.get("rows") if not isinstance(rows, list) or not rows: raise ValueError("LEARNED_POLICY_TRAINING_ROWS_REQUIRED") - return [row for row in rows if isinstance(row, Mapping)] + if not all(isinstance(row, Mapping) for row in rows): + raise ValueError("LEARNED_POLICY_TRAINING_ROW_INVALID") + return rows def policy_runtime_contract() -> dict[str, Any]: From c719a174cf397cf14ad1e0f8b9303e3a4e8fd4bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 08:51:17 +0700 Subject: [PATCH 12/21] test(v21): make Learned Policy UAT behavioral Replace source-text ordering inspection with a real ContextAwareReplyBrain fixture that records Learned Policy selection before frontier model generation, and surface sealed-runtime process launch failures explicitly. Yance-Independent-Review: CodeRabbit PR #384 Yance-Review-Findings: uat-source-ordering,uat-spawn-launch-error --- .../v21LearningPolicyClosedLoopEvidence.js | 145 +++++++++++++++++- 1 file changed, 137 insertions(+), 8 deletions(-) diff --git a/tools/uat/v21LearningPolicyClosedLoopEvidence.js b/tools/uat/v21LearningPolicyClosedLoopEvidence.js index dddeb23ee..a68267a14 100644 --- a/tools/uat/v21LearningPolicyClosedLoopEvidence.js +++ b/tools/uat/v21LearningPolicyClosedLoopEvidence.js @@ -5,6 +5,7 @@ const cp = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'yance-v21-policy-uat-')); process.env.YANCE_DATA_DIR = root; @@ -18,6 +19,9 @@ const { createLearningOutcomeAttributionService } = require('../../backend/servi const { createLearningDeepTrainingContract } = require('../../backend/services/learningDeepTrainingContract'); const { createLearningPolicyRuntimeAdapter } = require('../../backend/services/learningPolicyRuntimeAdapter'); const { createLearningPromotionAdapter } = require('../../backend/services/learningPromotionAdapter'); +const { createContextAwareReplyBrain } = require('../../backend/services/contextAwareReplyBrain'); +const contactContextAuthority = require('../../backend/services/contactContextAuthority'); +const { createPersonaBrain } = require('../../backend/personaBrain'); function runPolicyPython(payload) { const pythonDir = path.resolve(__dirname, '../../runtime/learning-growth/python'); @@ -27,12 +31,141 @@ function runPolicyPython(payload) { ['run', '--frozen', '--offline', 'python', entrypoint], { cwd: pythonDir, input: JSON.stringify(payload), encoding: 'utf8' } ); + if (result.error) { + throw new Error(`sealed policy runtime launch failed: ${result.error.message}`); + } if (result.status !== 0) { throw new Error(`sealed policy runtime failed (${result.status}): ${result.stderr}\n${result.stdout}`); } return JSON.parse(result.stdout); } +function makePersonaStore() { + const db = new DatabaseSync(':memory:'); + return { + db, + transaction(fn) { + db.exec('BEGIN'); + try { + const result = fn(); + db.exec('COMMIT'); + return result; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + } + }; +} + +function stubReplySocialContext(contactId) { + const entityVersions = { customer: 1, relationship: 1, memory: 1, interactionPolicy: 1, routing: 1 }; + return { + found: true, + ready: true, + contactId, + contextVersion: 1, + entityVersions, + guards: { canGenerateReply: true }, + relationshipPotential: { relationshipStage: 'familiar' }, + emotion: { trend: 'stable', current: 'neutral' }, + interaction: {}, + preferences: {}, + interactionPolicy: { policy: 'p' }, + replyStrategy: { maxQuestions: 1 }, + memory: { + confirmedFacts: [], userNotes: [], importantEvents: [], openLoops: [], + promises: [], boundaries: [], sensitiveTopics: [], recurringInterests: [] + }, + timeline: [], + recentSignals: [], + recentMessages: [] + }; +} + +async function provePolicyConsumedBeforeGeneration(decisionContract) { + const order = []; + const personaStore = makePersonaStore(); + const personaBrain = createPersonaBrain({ store: personaStore }); + personaBrain.service.initialize({}); + const originalGetSocialContext = contactContextAuthority.getSocialContext; + contactContextAuthority.getSocialContext = () => stubReplySocialContext('contact-order-uat'); + const storeManager = { + select: () => stubReplySocialContext('contact-order-uat'), + async dispatch(command) { + if (command.type === 'AI_REPLY_TASK_STARTED') return { result: { taskId: 'task-order-uat' } }; + if (command.type === 'AI_REPLY_CANDIDATE_READY') return { result: { candidateId: 'candidate-order-uat' } }; + return { result: {} }; + } + }; + const aiGateway = { + async execute(input) { + if (input.task === 'director') { + order.push('director'); + return { + text: JSON.stringify({ + strategy: 'natural continuation', + reasonZh: '自然承接对方最新消息', + goal: 'continue', + tone: 'warm', + pace: 'light', + instruction: 'reply naturally', + avoid: 'invented facts', + targetLanguage: 'unknown', + maxQuestions: 1 + }), + modelId: 'director-order-uat', + model: 'Director' + }; + } + order.push('frontier'); + return { text: 'hi', modelId: 'frontier-order-uat', model: 'Frontier' }; + } + }; + const learningPolicyRuntimeAdapter = { + async selectLearnedPolicyAction(input) { + order.push('policy'); + const action = input.allowedActions.includes('natural_hook') ? 'natural_hook' : input.allowedActions[0]; + return { + authority: 'LearningPolicyRuntimeAdapter', + candidateStrategyBranch: action, + policyVersion: 'vw-p1-baseline-v1', + policyArtifactId: 'baseline', + actionProbability: 1, + exploration: false, + degradation: null, + executedPolicy: 'baseline' + }; + } + }; + + try { + const brain = createContextAwareReplyBrain({ + storeManager, + aiGateway, + personaBrain, + learningPolicyRuntimeAdapter, + learningPolicyDecisionContract: decisionContract + }); + await brain.generateCandidate({ + contactId: 'contact-order-uat', + conversationId: 'conversation-order-uat', + incomingMessage: { id: 'message-order-uat', text: 'hello' }, + director: { persona: 'warm' }, + aggregateIncoming: false, + skipQuietWindow: true + }); + } finally { + contactContextAuthority.getSocialContext = originalGetSocialContext; + personaStore.db.close(); + } + const policyIndex = order.indexOf('policy'); + const frontierIndex = order.indexOf('frontier'); + assert.ok(policyIndex >= 0, `Learned Policy selection was not observed: ${order.join(' -> ')}`); + assert.ok(frontierIndex > policyIndex, `Frontier generation ran before Learned Policy selection: ${order.join(' -> ')}`); + return Object.freeze({ order: Object.freeze([...order]), policyIndex, frontierIndex }); +} + async function main() { const store = new R32SqliteStore({ dbPath: path.join(root, 'policy-uat.db') }); const repository = createPlatformCoreRepository({ storeProvider: () => store }); @@ -256,13 +389,8 @@ async function main() { assert.equal(rolledBack.automaticPromotion, false); assert.equal(rolledBack.evidenceId, 'uat-rollback-evidence'); - const brainSource = fs.readFileSync( - path.resolve(__dirname, '../../backend/services/contextAwareReplyBrain.js'), - 'utf8' - ); - const selectionIndex = brainSource.indexOf('selectLearnedPolicyAction('); - const finalFrontierIndex = brainSource.indexOf('let modelResult = await aiGateway.execute', selectionIndex); - assert.ok(selectionIndex >= 0 && finalFrontierIndex > selectionIndex); + const orderProof = await provePolicyConsumedBeforeGeneration(decisionContract); + assert.ok(orderProof.frontierIndex > orderProof.policyIndex); const receipt = { workPackage: 'V21-LEARNING-POLICY-P1-DECISION-OUTCOME-CLOSED-LOOP-V2-SUCCESSOR', @@ -278,7 +406,8 @@ async function main() { providerPrivacyBoundaryProved: true, learner: 'VowpalWabbit 9.11.2', frontierGenerationAuthority: 'Model Brain / LiteLLM', - policyConsumedBeforeGeneration: true, + policyConsumedBeforeGeneration: orderProof.frontierIndex > orderProof.policyIndex, + policyConsumptionOrder: orderProof.order, promotionAuthority: 'Learning', availabilityFallbackProved: true, rollbackProved: true, From fc20f3f17b5adac814e36785491a3e7157e401b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:23:08 +0700 Subject: [PATCH 13/21] test(v21): cover nested Learning runtime seal inventory --- ...v21-learning-policy-p1-supply-chain.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/wp0/v21-learning-policy-p1-supply-chain.test.js b/tests/wp0/v21-learning-policy-p1-supply-chain.test.js index 3360b8dad..a3cd35d32 100644 --- a/tests/wp0/v21-learning-policy-p1-supply-chain.test.js +++ b/tests/wp0/v21-learning-policy-p1-supply-chain.test.js @@ -3,6 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const ROOT = path.resolve(__dirname, '..', '..'); @@ -23,3 +24,20 @@ test('Vowpal Wabbit supply chain is pinned to 9.11.2 with exact upstream provena assert.match(notices, /BSD-3-Clause/i); assert.match(license, /Redistribution and use in source and binary forms/i); }); + +test('Learning runtime inventory excludes only the root runtime-seal.json', () => { + const { presealedLearningRuntimeRecords } = require('../../tools/wp7/lib'); + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yance-learning-runtime-inventory-')); + try { + fs.writeFileSync(path.join(runtimeRoot, 'runtime-seal.json'), 'root seal', 'utf8'); + fs.mkdirSync(path.join(runtimeRoot, 'nested'), { recursive: true }); + fs.writeFileSync(path.join(runtimeRoot, 'nested', 'runtime-seal.json'), 'nested seal', 'utf8'); + fs.writeFileSync(path.join(runtimeRoot, 'payload.txt'), 'payload', 'utf8'); + + const paths = presealedLearningRuntimeRecords(runtimeRoot).map(record => record.path); + assert.equal(paths.includes('runtime-seal.json'), false); + assert.equal(paths.includes('nested/runtime-seal.json'), true); + } finally { + fs.rmSync(runtimeRoot, { recursive: true, force: true }); + } +}); From e5e4786dc82eed034f6763ce81ca9de09590123a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:28:06 +0700 Subject: [PATCH 14/21] fix(v21): isolate injected Learned Policy runtime seam --- backend/services/learningPolicyRuntimeAdapter.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/services/learningPolicyRuntimeAdapter.js b/backend/services/learningPolicyRuntimeAdapter.js index cfa27a13f..96025bfb3 100644 --- a/backend/services/learningPolicyRuntimeAdapter.js +++ b/backend/services/learningPolicyRuntimeAdapter.js @@ -176,7 +176,12 @@ function createLearningPolicyRuntimeAdapter(options = {}) { const hasInjectedRuntime = typeof options.invokeVowpalWabbit === 'function'; const hasInjectedResolver = typeof options.resolveActivePolicy === 'function'; const invokeVowpalWabbit = hasInjectedRuntime ? options.invokeVowpalWabbit : invokeProductionVowpalWabbit; - const resolveActivePolicy = hasInjectedResolver ? options.resolveActivePolicy : resolveProductionActivePolicy; + // An injected sealed runtime is an explicit test/UAT seam. Unless that seam + // also supplies its own resolver, it must not pierce into production flagd + // state or create production-lifecycle file watchers. + const resolveActivePolicy = hasInjectedResolver + ? options.resolveActivePolicy + : (hasInjectedRuntime ? async () => null : resolveProductionActivePolicy); const onDegradation = typeof options.onDegradation === 'function' ? options.onDegradation : null; function baseline(input, reasonCode = 'NO_PROMOTED_POLICY') { From 55de3a96759f46d3ed36650da0ef72daa0556ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:34:18 +0700 Subject: [PATCH 15/21] test(v21): require observable LKG degradation fallback --- .../v21-learning-policy-p1-vw-runtime.test.js | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js b/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js index d0cfb13fd..c2d3964c7 100644 --- a/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js +++ b/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js @@ -3,12 +3,18 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); +const crypto = require('node:crypto'); const ROOT = path.resolve(__dirname, '..', '..'); const read = p => fs.readFileSync(path.join(ROOT, p), 'utf8'); -const { createLearningPolicyRuntimeAdapter } = require('../../backend/services/learningPolicyRuntimeAdapter'); +const { + ACTIVE_FLAG_KEY, + createLearningPolicyRuntimeAdapter, + resolveProductionActivePolicy +} = require('../../backend/services/learningPolicyRuntimeAdapter'); test('Learning runtime adapter delegates the action head to sealed Vowpal Wabbit and keeps P1 deterministic', async () => { const calls = []; @@ -31,6 +37,67 @@ test('Learning runtime adapter delegates the action head to sealed Vowpal Wabbit assert.equal(decision.finalReply, undefined); }); +test('production Learning runtime falls back to verified canonical history and preserves active-artifact degradation', async () => { + const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yance-learning-policy-lkg-')); + const learnedRoot = path.join(dataRoot, 'learning', 'learned-policy'); + const flagRoot = path.join(learnedRoot, 'flagd'); + const artifactRoot = path.join(learnedRoot, 'artifacts'); + fs.mkdirSync(flagRoot, { recursive: true }); + fs.mkdirSync(artifactRoot, { recursive: true }); + + const goodBytes = Buffer.from('verified-learning-policy-lkg\n', 'utf8'); + const goodVersion = crypto.createHash('sha256').update(goodBytes).digest('hex'); + const brokenVersion = 'f'.repeat(64); + fs.writeFileSync(path.join(artifactRoot, `${goodVersion}.vw`), goodBytes); + fs.writeFileSync(path.join(flagRoot, 'flags.json'), `${JSON.stringify({ + flags: { + [ACTIVE_FLAG_KEY]: { + state: 'ENABLED', + variants: { + active: { + kind: 'LEARNING_ROLLOUT', + candidate: { id: `policy:${brokenVersion}`, version: brokenVersion, policyVersion: 'vw-p1-v1' }, + history: [{ id: `policy:${goodVersion}`, version: goodVersion, policyVersion: 'vw-p1-v1' }] + } + }, + defaultVariant: 'active' + } + } + }, null, 2)}\n`, 'utf8'); + + const previousDataRoot = process.env.YANCE_DATA_DIR; + process.env.YANCE_DATA_DIR = dataRoot; + const degradations = []; + try { + const adapter = createLearningPolicyRuntimeAdapter({ + resolveActivePolicy: resolveProductionActivePolicy, + invokeVowpalWabbit: async input => ({ + action: 'natural_hook', + policyVersion: input.policyVersion, + policyArtifactId: input.policyArtifactId, + probability: 1, + exploration: false + }), + onDegradation: evidence => degradations.push(evidence) + }); + const decision = await adapter.selectLearnedPolicyAction({ + featureBundle: { relationshipStage: 'warming', interactionBand: 'balanced' }, + allowedActions: ['natural_hook', 'playful_attraction'] + }); + + assert.equal(decision.executedPolicy, 'vowpalwabbit'); + assert.equal(decision.policyArtifactId, goodVersion); + assert.equal(decision.degradation?.reasonCode, 'LEARNING_POLICY_ARTIFACT_MISSING'); + assert.equal(degradations.some(row => row.reasonCode === 'LEARNING_POLICY_ARTIFACT_MISSING'), true); + } finally { + const { OpenFeature, NOOP_PROVIDER } = require('@openfeature/server-sdk'); + await OpenFeature.setProviderAndWait('yance-learning-policy', NOOP_PROVIDER); + if (previousDataRoot === undefined) delete process.env.YANCE_DATA_DIR; + else process.env.YANCE_DATA_DIR = previousDataRoot; + fs.rmSync(dataRoot, { recursive: true, force: true }); + } +}); + 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); From f9fa314a10edad2ee9e58487fabc28bd7e832aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:36:58 +0700 Subject: [PATCH 16/21] fix(v21): preserve degraded LKG policy fallback --- .../services/learningPolicyRuntimeAdapter.js | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/backend/services/learningPolicyRuntimeAdapter.js b/backend/services/learningPolicyRuntimeAdapter.js index 96025bfb3..b1069182f 100644 --- a/backend/services/learningPolicyRuntimeAdapter.js +++ b/backend/services/learningPolicyRuntimeAdapter.js @@ -98,10 +98,30 @@ async function resolveProductionActivePolicy() { const client = await flagdClientFor(roots.flagFile); const rollout = await client.getObjectValue(ACTIVE_FLAG_KEY, null); if (!rollout || rollout.kind !== 'LEARNING_ROLLOUT') return null; - // Runtime consumption is fail-safe against the active rollout only. History - // is rollback evidence/authority and must never silently substitute a broken - // active artifact without an explicit rollback receipt. - return validatePolicyCandidate(rollout.candidate, roots); + + const candidates = [ + rollout.candidate, + ...(Array.isArray(rollout.history) ? rollout.history : []) + ].filter(Boolean); + let activeError = null; + for (let index = 0; index < candidates.length; index += 1) { + try { + const resolved = validatePolicyCandidate(candidates[index], roots); + if (index === 0 || !activeError) return resolved; + return deepFreeze({ + ...resolved, + degradation: { + reasonCode: clean(activeError.reasonCode || activeError.code) || 'LEARNING_POLICY_ACTIVE_VERIFICATION_FAILED', + activeCandidateRejected: true, + fallbackSource: 'canonical-rollout-history' + } + }); + } catch (error) { + if (index === 0) activeError = error; + } + } + if (activeError) throw activeError; + return null; } function sealedLearningRuntimePaths() { @@ -216,6 +236,14 @@ function createLearningPolicyRuntimeAdapter(options = {}) { // request-supplied active policy, artifact path, hash, or executable authority. if (!activePolicy && !hasInjectedRuntime) return baseline({ ...input, allowedActions }, 'NO_PROMOTED_POLICY'); + const resolutionDegradation = activePolicy?.degradation || null; + if (resolutionDegradation) { + onDegradation?.({ + ...resolutionDegradation, + policyArtifactId: clean(activePolicy?.policyArtifactId) + }); + } + try { const result = await invokeVowpalWabbit({ operation: 'policy_predict', @@ -240,7 +268,7 @@ function createLearningPolicyRuntimeAdapter(options = {}) { policyArtifactId: clean(result?.policyArtifactId || result?.policyArtifactVersion || activePolicy?.policyArtifactId) || 'baseline', actionProbability: 1, exploration: false, - degradation: null, + degradation: resolutionDegradation, executedPolicy: 'vowpalwabbit' }); } catch (error) { From 15ffdbdba0ba741c8067475b0f2ef2c6317a4215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:40:37 +0700 Subject: [PATCH 17/21] fix(v21): scope Learning runtime seal exclusion to root --- tools/wp7/lib.js | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/tools/wp7/lib.js b/tools/wp7/lib.js index ce110ab8d..c3053933a 100644 --- a/tools/wp7/lib.js +++ b/tools/wp7/lib.js @@ -433,8 +433,8 @@ function presealedLearningRuntimeRecords(runtimeRoot) { if (entry.isSymbolicLink()) throw new Wp7Error('WP7_LEARNING_RUNTIME_SYMLINK_REJECTED', 'symlinks are forbidden in the presealed Learning runtime', { path: absolute }); if (entry.isDirectory()) visit(absolute); else if (entry.isFile()) { - if (entry.name === 'runtime-seal.json') continue; const relative = path.relative(root, absolute).split(path.sep).join('/'); + if (relative === 'runtime-seal.json') continue; const stat = fs.statSync(absolute); records.push(Object.freeze({ path: relative, sizeBytes: stat.size, sha256: sha256File(absolute) })); } else throw new Wp7Error('WP7_LEARNING_RUNTIME_INVALID', 'unsupported file type in presealed Learning runtime', { path: absolute }); @@ -563,8 +563,7 @@ function installReleasePlatformAuth(resourcesRoot, options = {}) { if (path.resolve(configInput) !== path.resolve(configPath)) fs.writeFileSync(configPath, bytes, { mode: 0o600 }); else fs.chmodSync(configPath, 0o600); const digest = sha256Buffer(bytes); - fs.writeFileSync(hashPath, `${digest} ${releasePlatformAuth.CONFIG_FILE} -`, { mode: 0o600 }); + fs.writeFileSync(hashPath, `${digest} ${releasePlatformAuth.CONFIG_FILE}\n`, { mode: 0o600 }); return Object.freeze({ configured: true, sealed: loaded.sealed === true, @@ -895,9 +894,6 @@ function buildPreReviewFixture(options = {}) { }; const provenancePath = path.join(outputRoot, 'build-provenance.json'); writeCanonicalJson(provenancePath, provenance); - // The two resources documents above are freshly generated WP7 pre-review metadata. - // Scan only the application payload for inherited WP1 artifacts so the generic - // WP1 scanner does not misclassify WP7's own finalReleaseEvidence=false marker. assertNoWp1Reuse(built.payloadRoot); const seal = { schemaVersion: 1, documentType: 'WP7_PRE_REVIEW_BUILD_SESSION_SEAL', status: 'SEALED_PIPELINE_TEST_ONLY', artifactClass: PIPELINE_TEST_ARTIFACT_CLASS, @@ -1024,7 +1020,7 @@ function validateCleanInstallEvidence(doc) { if (missing.length) throw new Wp7Error('WP7_CLEAN_INSTALL_EVIDENCE_INCOMPLETE', 'clean-install evidence incomplete', { missing }); if (doc.finalInstallationMode !== 'CLEAN_INSTALL' || doc.legacyTestDataMigrationAttempted !== false || doc.legacyTestVersionRollbackAttempted !== false) throw new Wp7Error('WP7_LEGACY_TEST_DATA_MIGRATION_FORBIDDEN', 'clean-install policy mismatch'); if (doc.remainingResidueCount !== 0) throw new Wp7Error('WP7_LEGACY_TEST_DATA_RESIDUE', 'legacy residue remains', { remainingResidueCount: doc.remainingResidueCount }); - if (doc.installerSha256VerifiedImmediatelyBeforeInstall !== true) throw new Wp7Error('WP7_PREINSTALL_INSTALLER_SHA256_MISMATCH', 'installer SHA256 was not verified immediately before install'); + if (doc.installerSha256VerifiedImmediatelyBeforeInstall !== true) throw new Wp7Error('WP7_PREINSTALL_INSTALLER_SHA256_MISMATCH', 'installer SHA256 mismatch', { expected, actual }); if (doc.firstStartFreshInitialization !== true) throw new Wp7Error('WP7_FIRST_START_NOT_CLEAN', 'first start did not initialize fresh state'); return { status: 'PASS' }; } @@ -1237,11 +1233,6 @@ function runNsisCompiler(options = {}) { return { status: 'PASS', outputFile: options.outputFile, sha256: sha256File(options.outputFile), stdout: result.stdout, sourceValidation, estimatedSizeBytes, estimatedSizeKb }; } -// Emit electron-updater update metadata (latest.yml + .blockmap) from the -// GENUINE installer binary produced by the same build. This is exactly the -// derivation electron-builder performs: path/size/sha512 of the real file and a -// blockmap of full-file chunks. These artifacts are written only once the real -// installer exists, so they always match the shipped binary (no pre-faked data). function emitUpdateMetadata(options = {}) { const installerPath = path.resolve(options.installerPath); if (!fs.existsSync(installerPath)) throw new Wp7Error('WP7_UPDATE_METADATA_INSTALLER_MISSING', 'cannot emit update metadata before a real installer exists'); @@ -1267,15 +1258,7 @@ function emitUpdateMetadata(options = {}) { `prerelease: ${prerelease}\n`; const latestYmlPath = path.join(options.outputRoot, 'latest.yml'); fs.writeFileSync(latestYmlPath, latestYml, 'utf8'); - // Real electron-updater blockmap. electron-updater's BlockMap schema (builder-util-runtime) - // is: - // { version: "1", files: [ { name, offset, checksums: string[], sizes: number[] } ] } - // Each entry in `checksums`/`sizes` describes one fixed-size chunk of the installer - // binary (sha512 per chunk). `offset` is the byte offset of the file within the - // package (0 for a standalone installer). The DifferentialDownloader parses this - // format natively for partial/differential downloads. We do NOT fabricate a single - // block or use a custom `blocks[]` shape. - const BLOCK_SIZE = 1024 * 1024; // 1 MiB chunks + const BLOCK_SIZE = 1024 * 1024; const checksums = []; const sizes = []; for (let pos = 0; pos < buf.length; pos += BLOCK_SIZE) { @@ -1390,9 +1373,6 @@ function buildAuthorizedFinalWindowsInstaller(options = {}) { } else if (options.requireSignedInstaller === true) { throw new Wp7Error('WP7_INSTALLER_AUTHENTICODE_SIGNATURE_REQUIRED', 'production release requires a signed installer before update metadata is emitted'); } - // Emit electron-updater metadata only AFTER optional Authenticode signing. - // Signing mutates the installer bytes; metadata generated before this point - // would contain stale SHA-512, size and blockmap values. const updateMeta = emitUpdateMetadata({ installerPath: outputFile, outputRoot, From b6474faeb2fba3f249fdaf9735ca8436b233fcff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:45:46 +0700 Subject: [PATCH 18/21] fix(v21): restore WP7 bytes around Learning seal repair --- tools/wp7/lib.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tools/wp7/lib.js b/tools/wp7/lib.js index c3053933a..3f56b9380 100644 --- a/tools/wp7/lib.js +++ b/tools/wp7/lib.js @@ -563,7 +563,8 @@ function installReleasePlatformAuth(resourcesRoot, options = {}) { if (path.resolve(configInput) !== path.resolve(configPath)) fs.writeFileSync(configPath, bytes, { mode: 0o600 }); else fs.chmodSync(configPath, 0o600); const digest = sha256Buffer(bytes); - fs.writeFileSync(hashPath, `${digest} ${releasePlatformAuth.CONFIG_FILE}\n`, { mode: 0o600 }); + fs.writeFileSync(hashPath, `${digest} ${releasePlatformAuth.CONFIG_FILE} +`, { mode: 0o600 }); return Object.freeze({ configured: true, sealed: loaded.sealed === true, @@ -894,6 +895,9 @@ function buildPreReviewFixture(options = {}) { }; const provenancePath = path.join(outputRoot, 'build-provenance.json'); writeCanonicalJson(provenancePath, provenance); + // The two resources documents above are freshly generated WP7 pre-review metadata. + // Scan only the application payload for inherited WP1 artifacts so the generic + // WP1 scanner does not misclassify WP7's own finalReleaseEvidence=false marker. assertNoWp1Reuse(built.payloadRoot); const seal = { schemaVersion: 1, documentType: 'WP7_PRE_REVIEW_BUILD_SESSION_SEAL', status: 'SEALED_PIPELINE_TEST_ONLY', artifactClass: PIPELINE_TEST_ARTIFACT_CLASS, @@ -1020,7 +1024,7 @@ function validateCleanInstallEvidence(doc) { if (missing.length) throw new Wp7Error('WP7_CLEAN_INSTALL_EVIDENCE_INCOMPLETE', 'clean-install evidence incomplete', { missing }); if (doc.finalInstallationMode !== 'CLEAN_INSTALL' || doc.legacyTestDataMigrationAttempted !== false || doc.legacyTestVersionRollbackAttempted !== false) throw new Wp7Error('WP7_LEGACY_TEST_DATA_MIGRATION_FORBIDDEN', 'clean-install policy mismatch'); if (doc.remainingResidueCount !== 0) throw new Wp7Error('WP7_LEGACY_TEST_DATA_RESIDUE', 'legacy residue remains', { remainingResidueCount: doc.remainingResidueCount }); - if (doc.installerSha256VerifiedImmediatelyBeforeInstall !== true) throw new Wp7Error('WP7_PREINSTALL_INSTALLER_SHA256_MISMATCH', 'installer SHA256 mismatch', { expected, actual }); + if (doc.installerSha256VerifiedImmediatelyBeforeInstall !== true) throw new Wp7Error('WP7_PREINSTALL_INSTALLER_SHA256_MISMATCH', 'installer SHA256 was not verified immediately before install'); if (doc.firstStartFreshInitialization !== true) throw new Wp7Error('WP7_FIRST_START_NOT_CLEAN', 'first start did not initialize fresh state'); return { status: 'PASS' }; } @@ -1233,6 +1237,11 @@ function runNsisCompiler(options = {}) { return { status: 'PASS', outputFile: options.outputFile, sha256: sha256File(options.outputFile), stdout: result.stdout, sourceValidation, estimatedSizeBytes, estimatedSizeKb }; } +// Emit electron-updater update metadata (latest.yml + .blockmap) from the +// GENUINE installer binary produced by the same build. This is exactly the +// derivation electron-builder performs: path/size/sha512 of the real file and a +// blockmap of full-file chunks. These artifacts are written only once the real +// installer exists, so they always match the shipped binary (no pre-faked data). function emitUpdateMetadata(options = {}) { const installerPath = path.resolve(options.installerPath); if (!fs.existsSync(installerPath)) throw new Wp7Error('WP7_UPDATE_METADATA_INSTALLER_MISSING', 'cannot emit update metadata before a real installer exists'); @@ -1258,7 +1267,15 @@ function emitUpdateMetadata(options = {}) { `prerelease: ${prerelease}\n`; const latestYmlPath = path.join(options.outputRoot, 'latest.yml'); fs.writeFileSync(latestYmlPath, latestYml, 'utf8'); - const BLOCK_SIZE = 1024 * 1024; + // Real electron-updater blockmap. electron-updater's BlockMap schema (builder-util-runtime) + // is: + // { version: "1", files: [ { name, offset, checksums: string[], sizes: number[] } ] } + // Each entry in `checksums`/`sizes` describes one fixed-size chunk of the installer + // binary (sha512 per chunk). `offset` is the byte offset of the file within the + // package (0 for a standalone installer). The DifferentialDownloader parses this + // format natively for partial/differential downloads. We do NOT fabricate a single + // block or use a custom `blocks[]` shape. + const BLOCK_SIZE = 1024 * 1024; // 1 MiB chunks const checksums = []; const sizes = []; for (let pos = 0; pos < buf.length; pos += BLOCK_SIZE) { @@ -1373,6 +1390,9 @@ function buildAuthorizedFinalWindowsInstaller(options = {}) { } else if (options.requireSignedInstaller === true) { throw new Wp7Error('WP7_INSTALLER_AUTHENTICODE_SIGNATURE_REQUIRED', 'production release requires a signed installer before update metadata is emitted'); } + // Emit electron-updater metadata only AFTER optional Authenticode signing. + // Signing mutates the installer bytes; metadata generated before this point + // would contain stale SHA-512, size and blockmap values. const updateMeta = emitUpdateMetadata({ installerPath: outputFile, outputRoot, From 9b5921be59587d0c58ea8cf5922e4ec6ec23c245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:48:19 +0700 Subject: [PATCH 19/21] test(v21): prove native promotion consumption in UAT --- .../v21LearningPolicyClosedLoopEvidence.js | 89 +++++++++++-------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/tools/uat/v21LearningPolicyClosedLoopEvidence.js b/tools/uat/v21LearningPolicyClosedLoopEvidence.js index a68267a14..dceab55d2 100644 --- a/tools/uat/v21LearningPolicyClosedLoopEvidence.js +++ b/tools/uat/v21LearningPolicyClosedLoopEvidence.js @@ -17,12 +17,17 @@ const feedback = require('../../backend/services/replyFeedbackLearningService'); const { createLearningPolicyDecisionContract } = require('../../backend/services/learningPolicyDecisionContract'); const { createLearningOutcomeAttributionService } = require('../../backend/services/learningOutcomeAttributionService'); const { createLearningDeepTrainingContract } = require('../../backend/services/learningDeepTrainingContract'); -const { createLearningPolicyRuntimeAdapter } = require('../../backend/services/learningPolicyRuntimeAdapter'); +const { + createLearningPolicyRuntimeAdapter, + resolveProductionActivePolicy +} = require('../../backend/services/learningPolicyRuntimeAdapter'); const { createLearningPromotionAdapter } = require('../../backend/services/learningPromotionAdapter'); const { createContextAwareReplyBrain } = require('../../backend/services/contextAwareReplyBrain'); const contactContextAuthority = require('../../backend/services/contactContextAuthority'); const { createPersonaBrain } = require('../../backend/personaBrain'); +let store = null; + function runPolicyPython(payload) { const pythonDir = path.resolve(__dirname, '../../runtime/learning-growth/python'); const entrypoint = path.join(pythonDir, 'learning_entrypoint.py'); @@ -167,7 +172,7 @@ async function provePolicyConsumedBeforeGeneration(decisionContract) { } async function main() { - const store = new R32SqliteStore({ dbPath: path.join(root, 'policy-uat.db') }); + store = new R32SqliteStore({ dbPath: path.join(root, 'policy-uat.db') }); const repository = createPlatformCoreRepository({ storeProvider: () => store }); const identityAuthority = { resolve({ contactId, conversationId }) { @@ -319,24 +324,6 @@ async function main() { assert.equal(predicted.probability, 1); assert.equal(predicted.exploration, false); - const runtimeAdapter = createLearningPolicyRuntimeAdapter({ - async invokeVowpalWabbit(input) { - return runPolicyPython({ - ...input, - artifactPath, - policyArtifactId: trained.policyArtifactVersion - }); - } - }); - const consumed = await runtimeAdapter.selectLearnedPolicyAction({ - featureBundle: decision.featureBundle, - allowedActions: decision.allowedActionSet, - baselineAction: 'natural_hook' - }); - assert.ok(decision.allowedActionSet.includes(consumed.candidateStrategyBranch)); - assert.equal(consumed.actionProbability, 1); - assert.equal(consumed.exploration, false); - const degraded = await createLearningPolicyRuntimeAdapter({ async invokeVowpalWabbit() { const error = new Error('corrupt artifact'); @@ -351,20 +338,18 @@ async function main() { assert.equal(degraded.executedPolicy, 'baseline'); assert.equal(degraded.degradation.reasonCode, 'LEARNING_POLICY_ARTIFACT_IDENTITY_MISMATCH'); - const promotion = createLearningPromotionAdapter({ - openFeature: { - setEvaluationContext() {} - }, - flagd: { - mode: 'in-process-offline' - } - }); + const candidateRoot = path.join(root, 'learning', 'learned-policy', 'candidates'); + fs.mkdirSync(candidateRoot, { recursive: true }); + const canonicalCandidatePath = path.join(candidateRoot, `${trained.policyArtifactVersion}.vw`); + fs.copyFileSync(artifactPath, canonicalCandidatePath); + const promotion = createLearningPromotionAdapter(); const proposal = { status: 'READY_FOR_REVIEW', Candidate: { id: `policy:${trained.policyArtifactVersion}`, version: trained.policyArtifactVersion, + policyVersion: 'vw-p1-uat-v1', exposure: 0 }, Regression: { passed: true }, @@ -375,20 +360,43 @@ async function main() { approved: true, evidence: { id: 'uat-promotion-evidence' } }); - assert.equal(active.kind, 'LEARNING_ROLLOUT'); assert.equal(active.automaticPromotion, false); assert.equal(active.flagd, 'in-process-offline'); + const runtimeAdapter = createLearningPolicyRuntimeAdapter({ + resolveActivePolicy: resolveProductionActivePolicy, + async invokeVowpalWabbit(input) { + return runPolicyPython(input); + } + }); + const consumed = await runtimeAdapter.selectLearnedPolicyAction({ + featureBundle: decision.featureBundle, + allowedActions: decision.allowedActionSet, + baselineAction: 'natural_hook' + }); + assert.ok(decision.allowedActionSet.includes(consumed.candidateStrategyBranch)); + assert.equal(consumed.actionProbability, 1); + assert.equal(consumed.exploration, false); + assert.equal(consumed.executedPolicy, 'vowpalwabbit'); + assert.equal(consumed.policyArtifactId, trained.policyArtifactVersion); + const rolledBack = await promotion.rollback(active, { approved: true, evidence: { id: 'uat-rollback-evidence' } }); - assert.equal(rolledBack.kind, 'LEARNING_ROLLBACK'); assert.equal(rolledBack.automaticPromotion, false); assert.equal(rolledBack.evidenceId, 'uat-rollback-evidence'); + const afterRollback = await runtimeAdapter.selectLearnedPolicyAction({ + featureBundle: decision.featureBundle, + allowedActions: decision.allowedActionSet, + baselineAction: 'natural_hook' + }); + assert.equal(afterRollback.executedPolicy, 'baseline'); + assert.equal(afterRollback.policyArtifactId, 'baseline'); + const orderProof = await provePolicyConsumedBeforeGeneration(decisionContract); assert.ok(orderProof.frontierIndex > orderProof.policyIndex); @@ -409,17 +417,26 @@ async function main() { policyConsumedBeforeGeneration: orderProof.frontierIndex > orderProof.policyIndex, policyConsumptionOrder: orderProof.order, promotionAuthority: 'Learning', + productionNativePromotionConsumed: consumed.policyArtifactId === trained.policyArtifactVersion, availabilityFallbackProved: true, - rollbackProved: true, + rollbackProved: afterRollback.executedPolicy === 'baseline', localReplyModelUsed: false, policyArtifactVersion: trained.policyArtifactVersion, selectedAction: consumed.candidateStrategyBranch }; process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); - store.close?.(); } -main().catch(error => { - console.error(error); - process.exitCode = 1; -}); \ No newline at end of file +main() + .catch(error => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + try { + const { OpenFeature, NOOP_PROVIDER } = require('@openfeature/server-sdk'); + await OpenFeature.setProviderAndWait('yance-learning-policy', NOOP_PROVIDER); + } catch {} + try { store?.close?.(); } catch {} + fs.rmSync(root, { recursive: true, force: true }); + }); From 155eb1c32769f73c366a075c4fc16329200489b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:48:56 +0700 Subject: [PATCH 20/21] test(v21): make explicit resolver absence authoritative --- .../v21-learning-policy-p1-vw-runtime.test.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js b/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js index c2d3964c7..2c8a67445 100644 --- a/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js +++ b/tests/wp0/v21-learning-policy-p1-vw-runtime.test.js @@ -37,6 +37,27 @@ test('Learning runtime adapter delegates the action head to sealed Vowpal Wabbit assert.equal(decision.finalReply, undefined); }); +test('explicit active-policy resolver absence wins over injected runtime seam and returns baseline', async () => { + let invoked = false; + const adapter = createLearningPolicyRuntimeAdapter({ + resolveActivePolicy: async () => null, + invokeVowpalWabbit: async () => { + invoked = true; + return { action: 'natural_hook', probability: 1, exploration: false }; + } + }); + const decision = await adapter.selectLearnedPolicyAction({ + featureBundle: { relationshipStage: 'warming', interactionBand: 'balanced' }, + allowedActions: ['natural_hook', 'playful_attraction'], + baselineAction: 'natural_hook' + }); + + assert.equal(invoked, false); + assert.equal(decision.executedPolicy, 'baseline'); + assert.equal(decision.policyArtifactId, 'baseline'); + assert.equal(decision.degradation, null); +}); + test('production Learning runtime falls back to verified canonical history and preserves active-artifact degradation', async () => { const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yance-learning-policy-lkg-')); const learnedRoot = path.join(dataRoot, 'learning', 'learned-policy'); From b44535522227174aa79c9bf47e31ea6db3fd96d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Sat, 15 Aug 2026 09:52:49 +0700 Subject: [PATCH 21/21] fix(v21): honor explicit no-active policy resolution --- backend/services/learningPolicyRuntimeAdapter.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/services/learningPolicyRuntimeAdapter.js b/backend/services/learningPolicyRuntimeAdapter.js index b1069182f..c10d6529f 100644 --- a/backend/services/learningPolicyRuntimeAdapter.js +++ b/backend/services/learningPolicyRuntimeAdapter.js @@ -232,9 +232,12 @@ function createLearningPolicyRuntimeAdapter(options = {}) { if (input.failClosed === true) throw error; return baseline({ ...input, allowedActions }, reasonCode); } - // Test/UAT may inject the sealed operation directly; production never accepts - // request-supplied active policy, artifact path, hash, or executable authority. - if (!activePolicy && !hasInjectedRuntime) return baseline({ ...input, allowedActions }, 'NO_PROMOTED_POLICY'); + // Test/UAT may inject the sealed operation directly without a resolver; an + // explicitly injected resolver remains authoritative when it reports that + // no promoted policy is active. + if (!activePolicy && (!hasInjectedRuntime || hasInjectedResolver)) { + return baseline({ ...input, allowedActions }, 'NO_PROMOTED_POLICY'); + } const resolutionDegradation = activePolicy?.degradation || null; if (resolutionDegradation) {