diff --git a/index.js b/index.js index de6a2f3..ebf0345 100644 --- a/index.js +++ b/index.js @@ -25,24 +25,13 @@ function readJsonSafe(p) { } function rejectPendingRun(statePath) { - try { - const { getRepoRoot } = require('./src/gep/paths'); - const { execSync } = require('child_process'); - const repoRoot = getRepoRoot(); - - execSync('git checkout -- .', { cwd: repoRoot, encoding: 'utf8', timeout: 30000 }); - execSync('git clean -fd', { cwd: repoRoot, encoding: 'utf8', timeout: 30000 }); - } catch (e) { - console.warn('[Loop] Pending run rollback failed: ' + (e.message || e)); - } - try { const state = readJsonSafe(statePath); if (state && state.last_run && state.last_run.run_id) { state.last_solidify = { run_id: state.last_run.run_id, rejected: true, - reason: 'loop_bridge_disabled_autoreject', + reason: 'loop_bridge_disabled_autoreject_no_rollback', timestamp: new Date().toISOString(), }; fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n', 'utf8'); @@ -184,7 +173,7 @@ async function main() { if (isPendingSolidify(stAfterRun)) { const cleared = rejectPendingRun(solidifyStatePath); if (cleared) { - console.warn('[Loop] Auto-rejected pending run because bridge is disabled in loop mode.'); + console.warn('[Loop] Auto-rejected pending run because bridge is disabled in loop mode (state only, no rollback).'); } } } @@ -284,16 +273,21 @@ async function main() { if (res && res.ok && !dryRun) { try { - const { shouldDistill, prepareDistillation } = require('./src/gep/skillDistiller'); + const { shouldDistill, prepareDistillation, autoDistill } = require('./src/gep/skillDistiller'); if (shouldDistill()) { - const dr = prepareDistillation(); - if (dr && dr.ok && dr.promptPath) { - console.log('\n[DISTILL_REQUEST]'); - console.log('Distillation prompt ready. Read the prompt file, process it with your LLM,'); - console.log('save the LLM response to a file, then run:'); - console.log(' node index.js distill --response-file='); - console.log('Prompt file: ' + dr.promptPath); - console.log('[/DISTILL_REQUEST]'); + const auto = autoDistill(); + if (auto && auto.ok && auto.gene) { + console.log('[Distiller] Auto-distilled gene: ' + auto.gene.id); + } else { + const dr = prepareDistillation(); + if (dr && dr.ok && dr.promptPath) { + console.log('\n[DISTILL_REQUEST]'); + console.log('Distillation prompt ready. Read the prompt file, process it with your LLM,'); + console.log('save the LLM response to a file, then run:'); + console.log(' node index.js distill --response-file='); + console.log('Prompt file: ' + dr.promptPath); + console.log('[/DISTILL_REQUEST]'); + } } } } catch (e) { @@ -528,3 +522,10 @@ async function main() { if (require.main === module) { main(); } + +module.exports = { + main, + readJsonSafe, + rejectPendingRun, + isPendingSolidify, +}; diff --git a/src/evolve.js b/src/evolve.js index 5832b6c..5be631e 100644 --- a/src/evolve.js +++ b/src/evolve.js @@ -39,6 +39,8 @@ const { getEvolutionDir } = require('./gep/paths'); const { shouldReflect, buildReflectionContext, recordReflection } = require('./gep/reflection'); const { loadNarrativeSummary } = require('./gep/narrativeMemory'); const { maybeReportIssue } = require('./gep/issueReporter'); +const { resolveStrategy } = require('./gep/strategy'); +const { expandSignals } = require('./gep/learningSignals'); const REPO_ROOT = getRepoRoot(); @@ -347,6 +349,73 @@ function getMutationDirective(logContent) { `; } +function computeAdaptiveStrategyPolicy(opts) { + const recentEvents = Array.isArray(opts && opts.recentEvents) ? opts.recentEvents : []; + const selectedGene = opts && opts.selectedGene ? opts.selectedGene : null; + const signals = Array.isArray(opts && opts.signals) ? opts.signals : []; + const baseStrategy = resolveStrategy({ signals: signals }); + + const tail = recentEvents.slice(-8); + let repairStreak = 0; + for (let i = tail.length - 1; i >= 0; i--) { + if (tail[i] && tail[i].intent === 'repair') repairStreak++; + else break; + } + let failureStreak = 0; + for (let i = tail.length - 1; i >= 0; i--) { + if (tail[i] && tail[i].outcome && tail[i].outcome.status === 'failed') failureStreak++; + else break; + } + + const antiPatterns = selectedGene && Array.isArray(selectedGene.anti_patterns) ? selectedGene.anti_patterns.slice(-5) : []; + const learningHistory = selectedGene && Array.isArray(selectedGene.learning_history) ? selectedGene.learning_history.slice(-6) : []; + const signalTags = new Set(expandSignals(signals, '')); + const overlappingAntiPatterns = antiPatterns.filter(function (ap) { + return ap && Array.isArray(ap.learning_signals) && ap.learning_signals.some(function (tag) { + return signalTags.has(String(tag)); + }); + }); + const hardFailures = overlappingAntiPatterns.filter(function (ap) { return ap && ap.mode === 'hard'; }).length; + const softFailures = overlappingAntiPatterns.filter(function (ap) { return ap && ap.mode !== 'hard'; }).length; + const recentSuccesses = learningHistory.filter(function (x) { return x && x.outcome === 'success'; }).length; + + const stagnation = signals.includes('stable_success_plateau') || + signals.includes('evolution_saturation') || + signals.includes('empty_cycle_loop_detected') || + failureStreak >= 3 || + repairStreak >= 3; + + const forceInnovate = stagnation && !signals.includes('log_error'); + const highRiskGene = hardFailures >= 1 || (softFailures >= 2 && recentSuccesses === 0); + const cautiousExecution = highRiskGene || failureStreak >= 2; + + let blastRadiusMaxFiles = selectedGene && selectedGene.constraints && Number.isFinite(Number(selectedGene.constraints.max_files)) + ? Number(selectedGene.constraints.max_files) + : 12; + if (cautiousExecution) blastRadiusMaxFiles = Math.max(2, Math.min(blastRadiusMaxFiles, 6)); + else if (forceInnovate) blastRadiusMaxFiles = Math.max(3, Math.min(blastRadiusMaxFiles, 10)); + + const directives = []; + directives.push('Base strategy: ' + baseStrategy.label + ' (' + baseStrategy.description + ')'); + if (forceInnovate) directives.push('Force strategy shift: prefer innovate over repeating repair/optimize.'); + if (highRiskGene) directives.push('Selected gene is high risk for current signals; keep blast radius narrow and prefer smallest viable change.'); + if (failureStreak >= 2) directives.push('Recent failure streak detected; avoid repeating recent failed approach.'); + directives.push('Target max files for this cycle: ' + blastRadiusMaxFiles + '.'); + + return { + name: baseStrategy.name, + label: baseStrategy.label, + description: baseStrategy.description, + forceInnovate: forceInnovate, + cautiousExecution: cautiousExecution, + highRiskGene: highRiskGene, + repairStreak: repairStreak, + failureStreak: failureStreak, + blastRadiusMaxFiles: blastRadiusMaxFiles, + directives: directives, + }; +} + const STATE_FILE = path.join(getEvolutionDir(), 'evolution_state.json'); const DORMANT_HYPOTHESIS_FILE = path.join(getEvolutionDir(), 'dormant_hypothesis.json'); var DORMANT_TTL_MS = 3600 * 1000; @@ -1178,6 +1247,7 @@ async function run() { const newCandidates = extractCapabilityCandidates({ recentSessionTranscript: recentMasterLog, signals, + recentFailedCapsules: readRecentFailedCapsules(50), }); for (const c of newCandidates) { try { @@ -1321,6 +1391,11 @@ async function run() { ? capsuleCandidates.map(c => (c && c.id ? String(c.id) : null)).filter(Boolean) : []; const selectedCapsuleId = capsulesUsed.length ? capsulesUsed[0] : null; + const strategyPolicy = computeAdaptiveStrategyPolicy({ + recentEvents, + selectedGene, + signals, + }); // Personality selection (natural selection + small mutation when triggered). // This state is persisted in MEMORY_DIR and is treated as an evolution control surface (not role-play). @@ -1351,9 +1426,9 @@ async function run() { tailAvgScore >= 0.7; const forceInnovation = String(process.env.FORCE_INNOVATION || process.env.EVOLVE_FORCE_INNOVATION || '').toLowerCase() === 'true'; - const mutationInnovateMode = !!IS_RANDOM_DRIFT || !!innovationPressure || !!forceInnovation; + const mutationInnovateMode = !!IS_RANDOM_DRIFT || !!innovationPressure || !!forceInnovation || !!strategyPolicy.forceInnovate; const mutationSignals = innovationPressure ? [...(Array.isArray(signals) ? signals : []), 'stable_success_plateau'] : signals; - const mutationSignalsEffective = forceInnovation + const mutationSignalsEffective = (forceInnovation || strategyPolicy.forceInnovate) ? [...(Array.isArray(mutationSignals) ? mutationSignals : []), 'force_innovation'] : mutationSignals; @@ -1454,10 +1529,13 @@ async function run() { console.warn('[SolidifyState] Failed to read git HEAD:', e && e.message || e); } - const maxFiles = - selectedGene && selectedGene.constraints && Number.isFinite(Number(selectedGene.constraints.max_files)) - ? Number(selectedGene.constraints.max_files) - : 12; + const maxFiles = strategyPolicy && Number.isFinite(Number(strategyPolicy.blastRadiusMaxFiles)) + ? Number(strategyPolicy.blastRadiusMaxFiles) + : ( + selectedGene && selectedGene.constraints && Number.isFinite(Number(selectedGene.constraints.max_files)) + ? Number(selectedGene.constraints.max_files) + : 12 + ); const blastRadiusEstimate = { files: Number.isFinite(maxFiles) && maxFiles > 0 ? maxFiles : 0, lines: Number.isFinite(maxFiles) && maxFiles > 0 ? Math.round(maxFiles * 80) : 0, @@ -1491,6 +1569,7 @@ async function run() { baseline_untracked: baselineUntracked, baseline_git_head: baselineHead, blast_radius_estimate: blastRadiusEstimate, + strategy_policy: strategyPolicy, active_task_id: activeTask ? (activeTask.id || activeTask.task_id || null) : null, active_task_title: activeTask ? (activeTask.title || null) : null, worker_assignment_id: activeTask ? (activeTask._worker_assignment_id || null) : null, @@ -1628,6 +1707,7 @@ ${mutationDirective} capabilityCandidatesPreview, externalCandidatesPreview, hubMatchedBlock, + strategyPolicy, failedCapsules: recentFailedCapsules, hubLessons, }); @@ -1716,5 +1796,4 @@ ${mutationDirective} } } -module.exports = { run }; - +module.exports = { run, computeAdaptiveStrategyPolicy }; diff --git a/src/gep/candidates.js b/src/gep/candidates.js index d7a56f7..6d1b3c0 100644 --- a/src/gep/candidates.js +++ b/src/gep/candidates.js @@ -1,3 +1,5 @@ +const { expandSignals } = require('./learningSignals'); + function stableHash(input) { // Deterministic lightweight hash (not cryptographic). const s = String(input || ''); @@ -56,13 +58,15 @@ function buildFiveQuestionsShape({ title, signals, evidence }) { }; } -function extractCapabilityCandidates({ recentSessionTranscript, signals }) { +function extractCapabilityCandidates({ recentSessionTranscript, signals, recentFailedCapsules }) { const candidates = []; + const signalList = Array.isArray(signals) ? signals : []; + const expandedTags = expandSignals(signalList, recentSessionTranscript); const toolCalls = extractToolCalls(recentSessionTranscript); const freq = countFreq(toolCalls); for (const [tool, count] of freq.entries()) { - if (count < 2) continue; + if (count < 3) continue; const title = `Repeated tool usage: ${tool}`; const evidence = `Observed ${count} occurrences of tool call marker for ${tool}.`; const shape = buildFiveQuestionsShape({ title, signals, evidence }); @@ -72,13 +76,13 @@ function extractCapabilityCandidates({ recentSessionTranscript, signals }) { title, source: 'transcript', created_at: new Date().toISOString(), - signals: Array.isArray(signals) ? signals : [], + signals: signalList, + tags: expandedTags, shape, }); } // Signals-as-candidates: capture recurring pain points as reusable capability shapes. - const signalList = Array.isArray(signals) ? signals : []; const signalCandidates = [ // Defensive signals { signal: 'log_error', title: 'Repair recurring runtime errors' }, @@ -105,10 +109,67 @@ function extractCapabilityCandidates({ recentSessionTranscript, signals }) { source: 'signals', created_at: new Date().toISOString(), signals: signalList, + tags: expandedTags, shape, }); } + var failedCapsules = Array.isArray(recentFailedCapsules) ? recentFailedCapsules : []; + var groups = {}; + var problemPriority = [ + 'problem:performance', + 'problem:protocol', + 'problem:reliability', + 'problem:stagnation', + 'problem:capability', + ]; + for (var i = 0; i < failedCapsules.length; i++) { + var fc = failedCapsules[i]; + if (!fc || fc.outcome && fc.outcome.status === 'success') continue; + var reason = String(fc.failure_reason || '').trim(); + var failureTags = expandSignals((fc.trigger || []).concat(signalList), reason).filter(function (t) { + return t.indexOf('problem:') === 0 || t.indexOf('risk:') === 0 || t.indexOf('area:') === 0 || t.indexOf('action:') === 0; + }); + if (failureTags.length === 0) continue; + var dominantProblem = null; + for (var p = 0; p < problemPriority.length; p++) { + if (failureTags.indexOf(problemPriority[p]) !== -1) { + dominantProblem = problemPriority[p]; + break; + } + } + var groupingTags = dominantProblem + ? [dominantProblem] + : failureTags.filter(function (tag) { return tag.indexOf('area:') === 0 || tag.indexOf('risk:') === 0; }).slice(0, 1); + var key = groupingTags.join('|'); + if (!groups[key]) groups[key] = { count: 0, tags: failureTags, reasons: [], gene: fc.gene || null }; + groups[key].count += 1; + if (reason) groups[key].reasons.push(reason); + } + + Object.keys(groups).forEach(function (key) { + var group = groups[key]; + if (!group || group.count < 2) return; + var title = 'Learn from recurring failed evolution paths'; + if (group.tags.indexOf('problem:performance') !== -1) title = 'Resolve recurring performance regressions'; + else if (group.tags.indexOf('problem:protocol') !== -1) title = 'Prevent recurring protocol and validation regressions'; + else if (group.tags.indexOf('problem:reliability') !== -1) title = 'Repair recurring reliability failures'; + else if (group.tags.indexOf('problem:stagnation') !== -1) title = 'Break repeated stagnation loops with a new strategy'; + else if (group.tags.indexOf('area:orchestration') !== -1) title = 'Stabilize task and orchestration behavior'; + var evidence = 'Observed ' + group.count + ' recent failed evolutions with similar learning tags. ' + + (group.reasons[0] ? 'Latest reason: ' + clip(group.reasons[0], 180) : ''); + candidates.push({ + type: 'CapabilityCandidate', + id: 'cand_' + stableHash('failed:' + key), + title: title, + source: 'failed_capsules', + created_at: new Date().toISOString(), + signals: signalList, + tags: group.tags, + shape: buildFiveQuestionsShape({ title: title, signals: signalList, evidence: evidence }), + }); + }); + // Dedup by id const seen = new Set(); return candidates.filter(c => { @@ -138,5 +199,5 @@ function renderCandidatesPreview(candidates, maxChars = 1400) { module.exports = { extractCapabilityCandidates, renderCandidatesPreview, + expandSignals, }; - diff --git a/src/gep/learningSignals.js b/src/gep/learningSignals.js new file mode 100644 index 0000000..f09873d --- /dev/null +++ b/src/gep/learningSignals.js @@ -0,0 +1,88 @@ +function unique(items) { + return Array.from(new Set((Array.isArray(items) ? items : []).filter(Boolean).map(function (x) { + return String(x).trim(); + }).filter(Boolean))); +} + +function add(tags, value) { + if (!value) return; + tags.push(String(value).trim()); +} + +function expandSignals(signals, extraText) { + var raw = Array.isArray(signals) ? signals.map(function (s) { return String(s); }) : []; + var tags = []; + + for (var i = 0; i < raw.length; i++) { + var signal = raw[i]; + add(tags, signal); + var base = signal.split(':')[0]; + if (base && base !== signal) add(tags, base); + } + + var text = (raw.join(' ') + ' ' + String(extraText || '')).toLowerCase(); + + if (/(error|exception|failed|unstable|log_error|runtime|429)/.test(text)) { + add(tags, 'problem:reliability'); + add(tags, 'action:repair'); + } + if (/(protocol|prompt|audit|gep|schema|drift)/.test(text)) { + add(tags, 'problem:protocol'); + add(tags, 'action:optimize'); + add(tags, 'area:prompt'); + } + if (/(perf|performance|bottleneck|latency|slow|throughput)/.test(text)) { + add(tags, 'problem:performance'); + add(tags, 'action:optimize'); + } + if (/(feature|capability_gap|user_feature_request|external_opportunity|stagnation recommendation)/.test(text)) { + add(tags, 'problem:capability'); + add(tags, 'action:innovate'); + } + if (/(stagnation|plateau|steady_state|saturation|empty_cycle_loop|loop_detected|recurring)/.test(text)) { + add(tags, 'problem:stagnation'); + add(tags, 'action:innovate'); + } + if (/(task|worker|heartbeat|hub|commitment|assignment|orchestration)/.test(text)) { + add(tags, 'area:orchestration'); + } + if (/(memory|narrative|reflection)/.test(text)) { + add(tags, 'area:memory'); + } + if (/(skill|dashboard)/.test(text)) { + add(tags, 'area:skills'); + } + if (/(validation|canary|rollback|constraint|blast radius|destructive)/.test(text)) { + add(tags, 'risk:validation'); + } + + return unique(tags); +} + +function geneTags(gene) { + if (!gene || typeof gene !== 'object') return []; + var inputs = []; + if (gene.category) inputs.push('action:' + String(gene.category).toLowerCase()); + if (Array.isArray(gene.signals_match)) inputs = inputs.concat(gene.signals_match); + if (typeof gene.id === 'string') inputs.push(gene.id); + if (typeof gene.summary === 'string') inputs.push(gene.summary); + return expandSignals(inputs, ''); +} + +function scoreTagOverlap(gene, signals) { + var signalTags = expandSignals(signals, ''); + var geneTagList = geneTags(gene); + if (signalTags.length === 0 || geneTagList.length === 0) return 0; + var signalSet = new Set(signalTags); + var hits = 0; + for (var i = 0; i < geneTagList.length; i++) { + if (signalSet.has(geneTagList[i])) hits++; + } + return hits; +} + +module.exports = { + expandSignals: expandSignals, + geneTags: geneTags, + scoreTagOverlap: scoreTagOverlap, +}; diff --git a/src/gep/prompt.js b/src/gep/prompt.js index 7a77a0b..bdfec3a 100644 --- a/src/gep/prompt.js +++ b/src/gep/prompt.js @@ -266,6 +266,7 @@ function buildGepPrompt({ recentHistory, failedCapsules, hubLessons, + strategyPolicy, }) { const parentValue = parentEventId ? `"${parentEventId}"` : 'null'; const selectedGeneId = selectedGene && selectedGene.id ? selectedGene.id : 'gene_'; @@ -289,6 +290,15 @@ ACTIVE STRATEGY (Generic): 3. Apply minimal, safe changes. 4. Validate changes strictly. 5. Solidify knowledge. +`.trim(); + } + let strategyPolicyBlock = ''; + if (strategyPolicy && Array.isArray(strategyPolicy.directives) && strategyPolicy.directives.length > 0) { + strategyPolicyBlock = ` +ADAPTIVE STRATEGY POLICY: +${strategyPolicy.directives.map((s, i) => `${i + 1}. ${s}`).join('\n')} +${strategyPolicy.forceInnovate ? 'You MUST prefer INNOVATE unless a critical blocking error is present.' : ''} +${strategyPolicy.cautiousExecution ? 'You MUST reduce blast radius and avoid broad refactors in this cycle.' : ''} `.trim(); } @@ -384,6 +394,7 @@ II. Directives & Logic 2. Selection: Selected Gene "${selectedGeneId}". ${strategyBlock} +${strategyPolicyBlock ? '\n' + strategyPolicyBlock : ''} 3. Execution: Apply changes (tool calls). Repair/Optimize: small/reversible. Innovate: new skills in \`skills//\`. 4. Validation: Run gene's validation steps. Fail = ROLLBACK. diff --git a/src/gep/selector.js b/src/gep/selector.js index b29886c..6c97a01 100644 --- a/src/gep/selector.js +++ b/src/gep/selector.js @@ -1,3 +1,6 @@ +const { scoreTagOverlap } = require('./learningSignals'); +const { captureEnvFingerprint } = require('./envFingerprint'); + function matchPatternToSignals(pattern, signals) { if (!pattern || !signals || signals.length === 0) return false; const p = String(pattern); @@ -30,12 +33,54 @@ function matchPatternToSignals(pattern, signals) { function scoreGene(gene, signals) { if (!gene || gene.type !== 'Gene') return 0; const patterns = Array.isArray(gene.signals_match) ? gene.signals_match : []; - if (patterns.length === 0) return 0; + var tagScore = scoreTagOverlap(gene, signals); + if (patterns.length === 0) return tagScore > 0 ? tagScore * 0.6 : 0; let score = 0; for (const pat of patterns) { if (matchPatternToSignals(pat, signals)) score += 1; } - return score; + return score + (tagScore * 0.6); +} + +function getEpigeneticBoostLocal(gene, envFingerprint) { + if (!gene || !Array.isArray(gene.epigenetic_marks)) return 0; + const platform = envFingerprint && envFingerprint.platform ? String(envFingerprint.platform) : ''; + const arch = envFingerprint && envFingerprint.arch ? String(envFingerprint.arch) : ''; + const nodeVersion = envFingerprint && envFingerprint.node_version ? String(envFingerprint.node_version) : ''; + const envContext = [platform, arch, nodeVersion].filter(Boolean).join('/') || 'unknown'; + const mark = gene.epigenetic_marks.find(function (m) { return m && m.context === envContext; }); + return mark ? Number(mark.boost) || 0 : 0; +} + +function scoreGeneLearning(gene, signals, envFingerprint) { + if (!gene || gene.type !== 'Gene') return 0; + var boost = 0; + + var history = Array.isArray(gene.learning_history) ? gene.learning_history.slice(-8) : []; + for (var i = 0; i < history.length; i++) { + var entry = history[i]; + if (!entry) continue; + if (entry.outcome === 'success') boost += 0.12; + else if (entry.mode === 'hard') boost -= 0.22; + else if (entry.mode === 'soft') boost -= 0.08; + } + + boost += getEpigeneticBoostLocal(gene, envFingerprint); + + if (Array.isArray(gene.anti_patterns) && gene.anti_patterns.length > 0) { + var overlapPenalty = 0; + var signalTags = new Set(require('./learningSignals').expandSignals(signals, '')); + var recentAntiPatterns = gene.anti_patterns.slice(-6); + for (var j = 0; j < recentAntiPatterns.length; j++) { + var anti = recentAntiPatterns[j]; + if (!anti || !Array.isArray(anti.learning_signals)) continue; + var overlap = anti.learning_signals.some(function (tag) { return signalTags.has(String(tag)); }); + if (overlap) overlapPenalty += anti.mode === 'hard' ? 0.4 : 0.18; + } + boost -= overlapPenalty; + } + + return Math.max(-1.5, Math.min(1.5, boost)); } // Population-size-dependent drift intensity. @@ -90,9 +135,11 @@ function selectGene(genes, signals, opts) { var DISTILLED_PREFIX = 'gene_distilled_'; var DISTILLED_SCORE_FACTOR = 0.8; + const envFingerprint = captureEnvFingerprint(); const scored = genesList .map(g => { var s = scoreGene(g, signals); + s += scoreGeneLearning(g, signals, envFingerprint); if (s > 0 && g.id && String(g.id).startsWith(DISTILLED_PREFIX)) s *= DISTILLED_SCORE_FACTOR; return { gene: g, score: s }; }) @@ -247,4 +294,3 @@ module.exports = { buildSelectorDecision, matchPatternToSignals, }; - diff --git a/src/gep/skillDistiller.js b/src/gep/skillDistiller.js index 904dfb3..ff10fef 100644 --- a/src/gep/skillDistiller.js +++ b/src/gep/skillDistiller.js @@ -4,6 +4,7 @@ var fs = require('fs'); var path = require('path'); var crypto = require('crypto'); var paths = require('./paths'); +var learningSignals = require('./learningSignals'); var DISTILLER_MIN_CAPSULES = parseInt(process.env.DISTILLER_MIN_CAPSULES || '10', 10) || 10; var DISTILLER_INTERVAL_HOURS = parseInt(process.env.DISTILLER_INTERVAL_HOURS || '24', 10) || 24; @@ -268,6 +269,126 @@ function distillRequestPath() { return path.join(paths.getMemoryDir(), 'distill_request.json'); } +function inferCategoryFromSignals(signals) { + var list = Array.isArray(signals) ? signals.map(function (s) { return String(s).toLowerCase(); }) : []; + if (list.some(function (s) { return s.indexOf('error') !== -1 || s.indexOf('fail') !== -1 || s.indexOf('reliability') !== -1; })) { + return 'repair'; + } + if (list.some(function (s) { return s.indexOf('feature') !== -1 || s.indexOf('capability') !== -1 || s.indexOf('stagnation') !== -1; })) { + return 'innovate'; + } + return 'optimize'; +} + +function chooseDistillationSource(data, analysis) { + var grouped = data && data.grouped ? data.grouped : {}; + var best = null; + Object.keys(grouped).forEach(function (geneId) { + var g = grouped[geneId]; + if (!g || g.total_count <= 0) return; + var score = (g.total_count * 2) + (g.avg_score || 0); + if (!best || score > best.score) { + best = { gene_id: geneId, group: g, score: score }; + } + }); + return best; +} + +function synthesizeGeneFromPatterns(data, analysis, existingGenes) { + var source = chooseDistillationSource(data, analysis); + if (!source || !source.group) return null; + + var group = source.group; + var existing = Array.isArray(existingGenes) ? existingGenes : []; + var sourceGene = existing.find(function (g) { return g && g.id === source.gene_id; }) || null; + + var triggerFreq = {}; + (group.triggers || []).forEach(function (arr) { + (Array.isArray(arr) ? arr : []).forEach(function (s) { + var k = String(s).toLowerCase(); + triggerFreq[k] = (triggerFreq[k] || 0) + 1; + }); + }); + var signalsMatch = Object.keys(triggerFreq) + .sort(function (a, b) { return triggerFreq[b] - triggerFreq[a]; }) + .slice(0, 6); + var summaryText = (group.summaries || []).slice(0, 5).join(' '); + var derivedTags = learningSignals.expandSignals(signalsMatch, summaryText) + .filter(function (tag) { return tag.indexOf('problem:') === 0 || tag.indexOf('area:') === 0; }) + .slice(0, 4); + signalsMatch = Array.from(new Set(signalsMatch.concat(derivedTags))); + if (signalsMatch.length === 0 && sourceGene && Array.isArray(sourceGene.signals_match)) { + signalsMatch = sourceGene.signals_match.slice(0, 6); + } + + var category = sourceGene && sourceGene.category ? sourceGene.category : inferCategoryFromSignals(signalsMatch); + var idSeed = { + type: 'Gene', + id: DISTILLED_ID_PREFIX + source.gene_id.replace(/^gene_/, '').replace(/^gene_distilled_/, ''), + category: category, + signals_match: signalsMatch, + strategy: sourceGene && Array.isArray(sourceGene.strategy) && sourceGene.strategy.length > 0 + ? sourceGene.strategy.slice(0, 4) + : [ + 'Identify the dominant repeated trigger pattern.', + 'Apply the smallest targeted change for that pattern.', + 'Run the narrowest validation that proves the regression is gone.', + 'Rollback immediately if validation fails.', + ], + }; + + var summaryBase = (group.summaries && group.summaries[0]) ? String(group.summaries[0]) : ''; + if (!summaryBase) { + summaryBase = 'Reusable strategy for repeated successful pattern: ' + signalsMatch.slice(0, 3).join(', '); + } + + var gene = { + type: 'Gene', + id: deriveDescriptiveId(idSeed), + summary: summaryBase.slice(0, 200), + category: category, + signals_match: signalsMatch, + preconditions: sourceGene && Array.isArray(sourceGene.preconditions) && sourceGene.preconditions.length > 0 + ? sourceGene.preconditions.slice(0, 4) + : ['repeated success pattern observed in recent capsules'], + strategy: idSeed.strategy, + constraints: { + max_files: sourceGene && sourceGene.constraints && Number(sourceGene.constraints.max_files) > 0 + ? Math.min(DISTILLED_MAX_FILES, Number(sourceGene.constraints.max_files)) + : DISTILLED_MAX_FILES, + forbidden_paths: sourceGene && sourceGene.constraints && Array.isArray(sourceGene.constraints.forbidden_paths) + ? sourceGene.constraints.forbidden_paths.slice(0, 6) + : ['.git', 'node_modules'], + }, + validation: sourceGene && Array.isArray(sourceGene.validation) && sourceGene.validation.length > 0 + ? sourceGene.validation.slice(0, 4) + : ['node --test'], + }; + + return gene; +} + +function finalizeDistilledGene(gene, requestLike, status) { + var state = readDistillerState(); + state.last_distillation_at = new Date().toISOString(); + state.last_data_hash = requestLike.data_hash; + state.last_gene_id = gene.id; + state.distillation_count = (state.distillation_count || 0) + 1; + writeDistillerState(state); + + appendJsonl(distillerLogPath(), { + timestamp: new Date().toISOString(), + data_hash: requestLike.data_hash, + input_capsule_count: requestLike.input_capsule_count, + analysis_summary: requestLike.analysis_summary, + synthesized_gene_id: gene.id, + validation_passed: true, + validation_errors: [], + status: status || 'success', + gene: gene, + }); +} + // --------------------------------------------------------------------------- // Derive a descriptive ID from gene content when the LLM gives a bad name // --------------------------------------------------------------------------- @@ -560,11 +681,67 @@ function completeDistillation(responseText) { return { ok: true, gene: gene }; } +function autoDistill() { + var data = collectDistillationData(); + if (data.successCapsules.length < DISTILLER_MIN_CAPSULES) { + return { ok: false, reason: 'insufficient_data' }; + } + + var state = readDistillerState(); + if (state.last_data_hash === data.dataHash) { + return { ok: false, reason: 'idempotent_skip' }; + } + + var analysis = analyzePatterns(data); + var assetsDir = paths.getGepAssetsDir(); + var existingGenesJson = readJsonIfExists(path.join(assetsDir, 'genes.json'), { genes: [] }); + var existingGenes = existingGenesJson.genes || []; + var rawGene = synthesizeGeneFromPatterns(data, analysis, existingGenes); + if (!rawGene) return { ok: false, reason: 'no_candidate_gene' }; + + var validation = validateSynthesizedGene(rawGene, existingGenes); + if (!validation.valid) { + appendJsonl(distillerLogPath(), { + timestamp: new Date().toISOString(), + data_hash: data.dataHash, + status: 'auto_validation_failed', + synthesized_gene_id: validation.gene ? validation.gene.id : null, + validation_errors: validation.errors, + }); + return { ok: false, reason: 'validation_failed', errors: validation.errors }; + } + + var gene = validation.gene; + gene._distilled_meta = { + distilled_at: new Date().toISOString(), + source_capsule_count: data.successCapsules.length, + data_hash: data.dataHash, + auto_distilled: true, + }; + + var assetStore = require('./assetStore'); + assetStore.upsertGene(gene); + finalizeDistilledGene(gene, { + data_hash: data.dataHash, + input_capsule_count: data.successCapsules.length, + analysis_summary: { + high_frequency_count: analysis.high_frequency.length, + drift_count: analysis.strategy_drift.length, + gap_count: analysis.coverage_gaps.length, + success_rate: Math.round(analysis.success_rate * 100) / 100, + }, + }, 'auto_success'); + + return { ok: true, gene: gene, auto: true }; +} + module.exports = { collectDistillationData: collectDistillationData, analyzePatterns: analyzePatterns, + synthesizeGeneFromPatterns: synthesizeGeneFromPatterns, prepareDistillation: prepareDistillation, completeDistillation: completeDistillation, + autoDistill: autoDistill, validateSynthesizedGene: validateSynthesizedGene, shouldDistill: shouldDistill, buildDistillationPrompt: buildDistillationPrompt, diff --git a/src/gep/solidify.js b/src/gep/solidify.js index 8b67cc2..dc0c075 100644 --- a/src/gep/solidify.js +++ b/src/gep/solidify.js @@ -670,6 +670,108 @@ function buildFailureReason(constraintCheck, validation, protocolViolations, can return reasons.join('; ').slice(0, 2000) || 'unknown'; } +function buildSoftFailureLearningSignals(opts) { + const { expandSignals } = require('./learningSignals'); + var signals = opts && Array.isArray(opts.signals) ? opts.signals : []; + var failureReason = opts && opts.failureReason ? String(opts.failureReason) : ''; + var violations = opts && Array.isArray(opts.violations) ? opts.violations : []; + var validationResults = opts && Array.isArray(opts.validationResults) ? opts.validationResults : []; + var validationText = validationResults + .filter(function (r) { return r && r.ok === false; }) + .map(function (r) { return [r.cmd, r.stderr, r.stdout].filter(Boolean).join(' '); }) + .join(' '); + return expandSignals(signals.concat(violations), failureReason + ' ' + validationText) + .filter(function (tag) { + return tag.indexOf('problem:') === 0 || tag.indexOf('risk:') === 0 || tag.indexOf('area:') === 0 || tag.indexOf('action:') === 0; + }); +} + +function classifyFailureMode(opts) { + var constraintViolations = opts && Array.isArray(opts.constraintViolations) ? opts.constraintViolations : []; + var protocolViolations = opts && Array.isArray(opts.protocolViolations) ? opts.protocolViolations : []; + var validation = opts && opts.validation ? opts.validation : null; + var canary = opts && opts.canary ? opts.canary : null; + + if (constraintViolations.some(function (v) { + var s = String(v || ''); + return /HARD CAP BREACH|CRITICAL_FILE_|critical_path_modified|forbidden_path touched|ethics:/i.test(s); + })) { + return { mode: 'hard', reasonClass: 'constraint_destructive', retryable: false }; + } + + if (protocolViolations.length > 0) { + return { mode: 'hard', reasonClass: 'protocol', retryable: false }; + } + + if (canary && !canary.ok && !canary.skipped) { + return { mode: 'hard', reasonClass: 'canary', retryable: false }; + } + + if (constraintViolations.length > 0) { + return { mode: 'hard', reasonClass: 'constraint', retryable: false }; + } + + if (validation && validation.ok === false) { + return { mode: 'soft', reasonClass: 'validation', retryable: true }; + } + + return { mode: 'soft', reasonClass: 'unknown', retryable: true }; +} + +function adaptGeneFromLearning(opts) { + var gene = opts && opts.gene && opts.gene.type === 'Gene' ? opts.gene : null; + if (!gene) return gene; + + var outcomeStatus = String(opts && opts.outcomeStatus || '').toLowerCase(); + var learningSignals = Array.isArray(opts && opts.learningSignals) ? opts.learningSignals : []; + var failureMode = opts && opts.failureMode && typeof opts.failureMode === 'object' + ? opts.failureMode + : { mode: 'soft', reasonClass: 'unknown', retryable: true }; + + if (!Array.isArray(gene.learning_history)) gene.learning_history = []; + if (!Array.isArray(gene.signals_match)) gene.signals_match = []; + + var seenSignal = new Set(gene.signals_match.map(function (s) { return String(s); })); + if (outcomeStatus === 'success') { + for (var i = 0; i < learningSignals.length; i++) { + var sig = String(learningSignals[i] || ''); + if (!sig || seenSignal.has(sig)) continue; + if (sig.indexOf('problem:') === 0 || sig.indexOf('area:') === 0) { + gene.signals_match.push(sig); + seenSignal.add(sig); + } + } + } + + gene.learning_history.push({ + at: nowIso(), + outcome: outcomeStatus || 'unknown', + mode: failureMode.mode || 'soft', + reason_class: failureMode.reasonClass || 'unknown', + retryable: !!failureMode.retryable, + learning_signals: learningSignals.slice(0, 12), + }); + if (gene.learning_history.length > 20) { + gene.learning_history = gene.learning_history.slice(gene.learning_history.length - 20); + } + + if (outcomeStatus === 'failed') { + if (!Array.isArray(gene.anti_patterns)) gene.anti_patterns = []; + var anti = { + at: nowIso(), + mode: failureMode.mode || 'soft', + reason_class: failureMode.reasonClass || 'unknown', + learning_signals: learningSignals.slice(0, 8), + }; + gene.anti_patterns.push(anti); + if (gene.anti_patterns.length > 12) { + gene.anti_patterns = gene.anti_patterns.slice(gene.anti_patterns.length - 12); + } + } + + return gene; +} + function rollbackTracked(repoRoot) { const mode = String(process.env.EVOLVER_ROLLBACK_MODE || 'hard').toLowerCase(); @@ -1156,6 +1258,23 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } const ts = nowIso(); const outcomeStatus = success ? 'success' : 'failed'; const score = clamp01(success ? 0.85 : 0.2); + const failureReason = !success ? buildFailureReason(constraintCheck, validation, protocolViolations, canary) : ''; + const failureMode = !success + ? classifyFailureMode({ + constraintViolations: constraintCheck.violations, + protocolViolations: protocolViolations, + validation: validation, + canary: canary, + }) + : { mode: 'none', reasonClass: null, retryable: false }; + const softFailureLearningSignals = !success + ? buildSoftFailureLearningSignals({ + signals, + failureReason, + violations: constraintCheck.violations, + validationResults: validation.results, + }) + : []; const selectedCapsuleId = lastRun && typeof lastRun.selected_capsule_id === 'string' && lastRun.selected_capsule_id.trim() @@ -1223,6 +1342,12 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } protocol_ok: protocolViolations.length === 0, protocol_violations: protocolViolations, memory_graph: memoryGraphPath(), + soft_failure: success ? null : { + learning_signals: softFailureLearningSignals, + retryable: !!failureMode.retryable, + class: failureMode.reasonClass, + mode: failureMode.mode, + }, }, }; event.asset_id = computeAssetId(event); @@ -1286,7 +1411,8 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } ? 'Failed: ' + geneUsed.id + ' on signals [' + (signals.slice(0, 3).join(', ') || 'none') + ']' : 'Failed evolution on signals [' + (signals.slice(0, 3).join(', ') || 'none') + ']', diff_snapshot: diffSnapshot, - failure_reason: buildFailureReason(constraintCheck, validation, protocolViolations, canary), + failure_reason: failureReason, + learning_signals: softFailureLearningSignals, constraint_violations: constraintCheck.violations || [], env_fingerprint: envFp, blast_radius: { files: blast.files, lines: blast.lines }, @@ -1314,6 +1440,12 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } // Apply epigenetic marks to the gene based on outcome and environment if (!dryRun && geneUsed && geneUsed.type === 'Gene') { try { + adaptGeneFromLearning({ + gene: geneUsed, + outcomeStatus: outcomeStatus, + learningSignals: success ? signals : softFailureLearningSignals, + failureMode: failureMode, + }); applyEpigeneticMarks(geneUsed, envFp, outcomeStatus); upsertGene(geneUsed); } catch (e) { @@ -1542,7 +1674,7 @@ function solidify({ intent, summary, dryRun = false, rollbackOnFailure = true } // which we already do above. The Hub-side solicitLesson() handles the rest. // For failures without a published event (no auto-publish), we still log locally. if (!dryRun && !success && event && event.outcome) { - var failureContent = buildFailureReason(constraintCheck, validation, protocolViolations, canary); + var failureContent = failureReason; event.failure_reason = failureContent; event.summary = geneUsed ? 'Failed: ' + geneUsed.id + ' on signals [' + (signals.slice(0, 3).join(', ') || 'none') + '] - ' + failureContent.slice(0, 200) @@ -1688,6 +1820,9 @@ module.exports = { classifyBlastSeverity, analyzeBlastRadiusBreakdown, compareBlastEstimate, + classifyFailureMode, + adaptGeneFromLearning, + buildSoftFailureLearningSignals, runCanaryCheck, applyEpigeneticMarks, getEpigeneticBoost, diff --git a/test/candidates.test.js b/test/candidates.test.js new file mode 100644 index 0000000..83490e2 --- /dev/null +++ b/test/candidates.test.js @@ -0,0 +1,28 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { extractCapabilityCandidates, expandSignals } = require('../src/gep/candidates'); + +describe('expandSignals', () => { + it('derives structured learning tags from weak signals', () => { + const tags = expandSignals(['perf_bottleneck', 'stable_success_plateau'], ''); + assert.ok(tags.includes('problem:performance')); + assert.ok(tags.includes('problem:stagnation')); + assert.ok(tags.includes('action:optimize')); + }); +}); + +describe('extractCapabilityCandidates', () => { + it('creates a failure-driven candidate from repeated failed capsules', () => { + const result = extractCapabilityCandidates({ + recentSessionTranscript: '', + signals: ['perf_bottleneck'], + recentFailedCapsules: [ + { trigger: ['perf_bottleneck'], failure_reason: 'validation failed because latency stayed high', outcome: { status: 'failed' } }, + { trigger: ['perf_bottleneck'], failure_reason: 'constraint violation after slow path regression', outcome: { status: 'failed' } }, + ], + }); + const failureCandidate = result.find(function (c) { return c.source === 'failed_capsules'; }); + assert.ok(failureCandidate); + assert.ok(failureCandidate.tags.includes('problem:performance')); + }); +}); diff --git a/test/evolvePolicy.test.js b/test/evolvePolicy.test.js new file mode 100644 index 0000000..c318d07 --- /dev/null +++ b/test/evolvePolicy.test.js @@ -0,0 +1,36 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { computeAdaptiveStrategyPolicy } = require('../src/evolve'); + +describe('computeAdaptiveStrategyPolicy', () => { + it('forces innovation after repeated repair/failure streaks', () => { + const policy = computeAdaptiveStrategyPolicy({ + signals: ['stable_success_plateau'], + selectedGene: { type: 'Gene', id: 'gene_x', constraints: { max_files: 20 } }, + recentEvents: [ + { intent: 'repair', outcome: { status: 'failed' } }, + { intent: 'repair', outcome: { status: 'failed' } }, + { intent: 'repair', outcome: { status: 'failed' } }, + ], + }); + assert.equal(policy.forceInnovate, true); + assert.ok(policy.blastRadiusMaxFiles <= 10); + }); + + it('shrinks blast radius for high-risk genes with overlapping anti-patterns', () => { + const policy = computeAdaptiveStrategyPolicy({ + signals: ['perf_bottleneck'], + selectedGene: { + type: 'Gene', + id: 'gene_perf', + constraints: { max_files: 18 }, + anti_patterns: [{ mode: 'hard', learning_signals: ['problem:performance'] }], + learning_history: [], + }, + recentEvents: [], + }); + assert.equal(policy.highRiskGene, true); + assert.ok(policy.blastRadiusMaxFiles <= 6); + assert.equal(policy.cautiousExecution, true); + }); +}); diff --git a/test/loopMode.test.js b/test/loopMode.test.js new file mode 100644 index 0000000..293b38d --- /dev/null +++ b/test/loopMode.test.js @@ -0,0 +1,70 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { rejectPendingRun } = require('../index.js'); + +describe('loop-mode auto reject', () => { + var tmpDir; + var originalRepoRoot; + var originalWorkspaceRoot; + var originalEvDir; + var originalMemoryDir; + var originalA2aHubUrl; + var originalHeartbeatMs; + var originalWorkerEnabled; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-loop-test-')); + originalRepoRoot = process.env.EVOLVER_REPO_ROOT; + originalWorkspaceRoot = process.env.OPENCLAW_WORKSPACE; + originalEvDir = process.env.EVOLUTION_DIR; + originalMemoryDir = process.env.MEMORY_DIR; + originalA2aHubUrl = process.env.A2A_HUB_URL; + originalHeartbeatMs = process.env.HEARTBEAT_INTERVAL_MS; + originalWorkerEnabled = process.env.WORKER_ENABLED; + process.env.EVOLVER_REPO_ROOT = tmpDir; + process.env.OPENCLAW_WORKSPACE = tmpDir; + process.env.EVOLUTION_DIR = path.join(tmpDir, 'memory', 'evolution'); + process.env.MEMORY_DIR = path.join(tmpDir, 'memory'); + process.env.A2A_HUB_URL = ''; + process.env.HEARTBEAT_INTERVAL_MS = '3600000'; + delete process.env.WORKER_ENABLED; + }); + + afterEach(() => { + if (originalRepoRoot === undefined) delete process.env.EVOLVER_REPO_ROOT; + else process.env.EVOLVER_REPO_ROOT = originalRepoRoot; + if (originalWorkspaceRoot === undefined) delete process.env.OPENCLAW_WORKSPACE; + else process.env.OPENCLAW_WORKSPACE = originalWorkspaceRoot; + if (originalEvDir === undefined) delete process.env.EVOLUTION_DIR; + else process.env.EVOLUTION_DIR = originalEvDir; + if (originalMemoryDir === undefined) delete process.env.MEMORY_DIR; + else process.env.MEMORY_DIR = originalMemoryDir; + if (originalA2aHubUrl === undefined) delete process.env.A2A_HUB_URL; + else process.env.A2A_HUB_URL = originalA2aHubUrl; + if (originalHeartbeatMs === undefined) delete process.env.HEARTBEAT_INTERVAL_MS; + else process.env.HEARTBEAT_INTERVAL_MS = originalHeartbeatMs; + if (originalWorkerEnabled === undefined) delete process.env.WORKER_ENABLED; + else process.env.WORKER_ENABLED = originalWorkerEnabled; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('marks pending runs rejected without deleting untracked files', () => { + const stateDir = path.join(tmpDir, 'memory', 'evolution'); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'evolution_solidify_state.json'), JSON.stringify({ + last_run: { run_id: 'run_123' } + }, null, 2)); + fs.writeFileSync(path.join(tmpDir, 'PR_BODY.md'), 'keep me\n'); + const changed = rejectPendingRun(path.join(stateDir, 'evolution_solidify_state.json')); + + const state = JSON.parse(fs.readFileSync(path.join(stateDir, 'evolution_solidify_state.json'), 'utf8')); + assert.equal(changed, true); + assert.equal(state.last_solidify.run_id, 'run_123'); + assert.equal(state.last_solidify.rejected, true); + assert.equal(state.last_solidify.reason, 'loop_bridge_disabled_autoreject_no_rollback'); + assert.equal(fs.readFileSync(path.join(tmpDir, 'PR_BODY.md'), 'utf8'), 'keep me\n'); + }); +}); diff --git a/test/selector.test.js b/test/selector.test.js index 3664eba..c2f7be4 100644 --- a/test/selector.test.js +++ b/test/selector.test.js @@ -27,6 +27,15 @@ const GENES = [ strategy: ['build it'], validation: ['node -e "true"'], }, + { + type: 'Gene', + id: 'gene_perf_optimize', + category: 'optimize', + signals_match: ['latency', 'throughput'], + summary: 'Reduce latency and improve throughput on slow paths', + strategy: ['speed it up'], + validation: ['node -e "true"'], + }, ]; const CAPSULES = [ @@ -93,6 +102,47 @@ describe('selectGene', () => { assert.ok(result.selected); assert.equal(result.selected.id, 'gene_innovate', 'innovate gene has signals_match user_improvement_suggestion'); }); + + it('uses derived learning tags to match related performance genes', () => { + const originalRandom = Math.random; + Math.random = () => 0.99; + try { + const result = selectGene(GENES, ['perf_bottleneck'], { effectivePopulationSize: 100 }); + assert.ok(result.selected); + assert.equal(result.selected.id, 'gene_perf_optimize'); + } finally { + Math.random = originalRandom; + } + }); + + it('downweights genes with repeated hard-fail anti-patterns', () => { + const riskyGenes = [ + { + type: 'Gene', + id: 'gene_perf_risky', + category: 'optimize', + signals_match: ['perf_bottleneck'], + anti_patterns: [ + { mode: 'hard', learning_signals: ['problem:performance'] }, + { mode: 'hard', learning_signals: ['problem:performance'] }, + ], + validation: ['node -e "true"'], + }, + { + type: 'Gene', + id: 'gene_perf_safe', + category: 'optimize', + signals_match: ['perf_bottleneck'], + learning_history: [ + { outcome: 'success', mode: 'none' }, + ], + validation: ['node -e "true"'], + }, + ]; + const result = selectGene(riskyGenes, ['perf_bottleneck'], { effectivePopulationSize: 100 }); + assert.ok(result.selected); + assert.equal(result.selected.id, 'gene_perf_safe'); + }); }); describe('selectCapsule', () => { diff --git a/test/skillDistiller.test.js b/test/skillDistiller.test.js index 1b7e6d1..7c9c11e 100644 --- a/test/skillDistiller.test.js +++ b/test/skillDistiller.test.js @@ -14,6 +14,8 @@ const { shouldDistill, prepareDistillation, completeDistillation, + autoDistill, + synthesizeGeneFromPatterns, distillRequestPath, readDistillerState, writeDistillerState, @@ -87,6 +89,10 @@ function writeGenes(genes) { ); } +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + // --- Tests --- describe('computeDataHash', () => { @@ -422,6 +428,71 @@ describe('prepareDistillation', () => { }); }); +describe('synthesizeGeneFromPatterns', () => { + beforeEach(setupTempEnv); + afterEach(teardownTempEnv); + + it('builds a conservative distilled gene from repeated successful capsules', () => { + writeCapsules([ + makeCapsule('c1', 'gene_perf', 'success', 0.95, ['perf_bottleneck', 'latency'], 'Reduced latency in hot path'), + makeCapsule('c2', 'gene_perf', 'success', 0.92, ['perf_bottleneck', 'throughput'], 'Improved throughput under load'), + makeCapsule('c3', 'gene_perf', 'success', 0.91, ['perf_bottleneck'], 'Cut slow-path overhead'), + makeCapsule('c4', 'gene_perf', 'success', 0.93, ['perf_bottleneck'], 'Optimized repeated slow query'), + makeCapsule('c5', 'gene_perf', 'success', 0.94, ['perf_bottleneck'], 'Reduced performance regressions'), + makeCapsule('c6', 'gene_perf', 'success', 0.96, ['perf_bottleneck'], 'Stabilized latency under peak load'), + makeCapsule('c7', 'gene_perf', 'success', 0.97, ['perf_bottleneck'], 'Optimized hot path validation'), + makeCapsule('c8', 'gene_perf', 'success', 0.98, ['perf_bottleneck'], 'Minimized repeated bottleneck'), + makeCapsule('c9', 'gene_perf', 'success', 0.99, ['perf_bottleneck'], 'Improved repeated performance pattern'), + makeCapsule('c10', 'gene_perf', 'success', 0.91, ['perf_bottleneck'], 'Kept repeated success on perf fixes'), + ]); + writeGenes([{ + type: 'Gene', + id: 'gene_perf', + category: 'optimize', + signals_match: ['perf_bottleneck'], + strategy: ['Profile the hot path', 'Apply the narrowest optimization', 'Run focused perf validation'], + constraints: { max_files: 8, forbidden_paths: ['.git', 'node_modules'] }, + validation: ['node --test'], + }]); + + var data = collectDistillationData(); + var analysis = analyzePatterns(data); + var gene = synthesizeGeneFromPatterns(data, analysis, [{ id: 'gene_perf', category: 'optimize', signals_match: ['perf_bottleneck'] }]); + assert.ok(gene); + assert.ok(gene.id.startsWith('gene_distilled_')); + assert.equal(gene.category, 'optimize'); + assert.ok(gene.signals_match.includes('perf_bottleneck')); + }); +}); + +describe('autoDistill', () => { + beforeEach(setupTempEnv); + afterEach(teardownTempEnv); + + it('writes a distilled gene automatically when enough successful capsules exist', () => { + var caps = []; + for (var i = 0; i < 10; i++) { + caps.push(makeCapsule('c' + i, 'gene_perf', 'success', 0.95, ['perf_bottleneck'], 'Reduce repeated latency regressions')); + } + writeCapsules(caps); + writeGenes([{ + type: 'Gene', + id: 'gene_perf', + category: 'optimize', + signals_match: ['perf_bottleneck'], + strategy: ['Profile the slow path', 'Apply a targeted optimization', 'Run validation'], + constraints: { max_files: 8, forbidden_paths: ['.git', 'node_modules'] }, + validation: ['node --test'], + }]); + + var result = autoDistill(); + assert.ok(result.ok, result.reason || 'autoDistill should succeed'); + assert.ok(result.gene.id.startsWith('gene_distilled_')); + var genes = readJson(path.join(process.env.GEP_ASSETS_DIR, 'genes.json')); + assert.ok(genes.genes.some(function (g) { return g.id === result.gene.id; })); + }); +}); + describe('completeDistillation', () => { beforeEach(setupTempEnv); afterEach(teardownTempEnv); diff --git a/test/solidifyLearning.test.js b/test/solidifyLearning.test.js new file mode 100644 index 0000000..29b86b2 --- /dev/null +++ b/test/solidifyLearning.test.js @@ -0,0 +1,86 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + classifyFailureMode, + adaptGeneFromLearning, + buildSoftFailureLearningSignals, +} = require('../src/gep/solidify'); + +describe('classifyFailureMode', () => { + it('treats validation-only failures as soft and retryable', () => { + const result = classifyFailureMode({ + constraintViolations: [], + protocolViolations: [], + validation: { ok: false, results: [{ ok: false, cmd: 'npm test' }] }, + canary: { ok: true, skipped: false }, + }); + assert.equal(result.mode, 'soft'); + assert.equal(result.reasonClass, 'validation'); + assert.equal(result.retryable, true); + }); + + it('treats destructive constraint failures as hard', () => { + const result = classifyFailureMode({ + constraintViolations: ['CRITICAL_FILE_DELETED: MEMORY.md'], + protocolViolations: [], + validation: { ok: true, results: [] }, + canary: { ok: true, skipped: false }, + }); + assert.equal(result.mode, 'hard'); + assert.equal(result.reasonClass, 'constraint_destructive'); + assert.equal(result.retryable, false); + }); +}); + +describe('adaptGeneFromLearning', () => { + it('adds structured success signals back into gene matching', () => { + const gene = { + type: 'Gene', + id: 'gene_test', + signals_match: ['error'], + }; + adaptGeneFromLearning({ + gene, + outcomeStatus: 'success', + learningSignals: ['problem:performance', 'action:optimize', 'area:orchestration'], + failureMode: { mode: 'none', reasonClass: null, retryable: false }, + }); + assert.ok(gene.signals_match.includes('problem:performance')); + assert.ok(gene.signals_match.includes('area:orchestration')); + assert.ok(!gene.signals_match.includes('action:optimize')); + assert.ok(Array.isArray(gene.learning_history)); + assert.equal(gene.learning_history[0].outcome, 'success'); + }); + + it('records failed anti-patterns without broadening matching', () => { + const gene = { + type: 'Gene', + id: 'gene_test_fail', + signals_match: ['protocol'], + }; + adaptGeneFromLearning({ + gene, + outcomeStatus: 'failed', + learningSignals: ['problem:protocol', 'risk:validation'], + failureMode: { mode: 'soft', reasonClass: 'validation', retryable: true }, + }); + assert.deepEqual(gene.signals_match, ['protocol']); + assert.ok(Array.isArray(gene.anti_patterns)); + assert.equal(gene.anti_patterns[0].mode, 'soft'); + }); +}); + +describe('buildSoftFailureLearningSignals', () => { + it('extracts structured tags from validation failures', () => { + const tags = buildSoftFailureLearningSignals({ + signals: ['perf_bottleneck'], + failureReason: 'validation_failed: npm test => latency remained high', + violations: [], + validationResults: [ + { ok: false, cmd: 'npm test', stderr: 'latency remained high', stdout: '' }, + ], + }); + assert.ok(tags.includes('problem:performance')); + assert.ok(tags.includes('risk:validation')); + }); +});